From 0cf9ca512070d58b8760d14e1677f3fd8f7fb039 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 11:34:44 +0200 Subject: [PATCH 01/71] Initial Backend file/class tree --- ixmp/backend/__init__.py | 1 + ixmp/backend/base.py | 2 ++ ixmp/backend/jdbc.py | 5 +++++ ixmp/core.py | 1 + 4 files changed, 9 insertions(+) create mode 100644 ixmp/backend/__init__.py create mode 100644 ixmp/backend/base.py create mode 100644 ixmp/backend/jdbc.py diff --git a/ixmp/backend/__init__.py b/ixmp/backend/__init__.py new file mode 100644 index 000000000..903f31b50 --- /dev/null +++ b/ixmp/backend/__init__.py @@ -0,0 +1 @@ +from .jdbc import JDBCBackend # noqa: F401 diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py new file mode 100644 index 000000000..07fe18045 --- /dev/null +++ b/ixmp/backend/base.py @@ -0,0 +1,2 @@ +class Backend: + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py new file mode 100644 index 000000000..2ce2088aa --- /dev/null +++ b/ixmp/backend/jdbc.py @@ -0,0 +1,5 @@ +from .base import Backend + + +class JDBCBackend(Backend): + pass diff --git a/ixmp/core.py b/ixmp/core.py index c555f970a..5553d795e 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -15,6 +15,7 @@ import ixmp as ix from ixmp import model_settings +from .backend import JDBCBackend # noqa: F401 from ixmp.config import _config from ixmp.utils import logger, islistable, check_year, harmonize_path From b3694b5e7c9fe93e031c7a3100c73c52cfdda7ec Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 12:14:01 +0200 Subject: [PATCH 02/71] Move __init__ code to JDBCBackend, pass tests --- .gitignore | 1 + ixmp/backend/__init__.py | 7 ++- ixmp/backend/base.py | 12 ++++- ixmp/backend/jdbc.py | 95 +++++++++++++++++++++++++++++++++++- ixmp/core.py | 103 ++++++++++----------------------------- tests/test_access.py | 5 +- tests/test_cli.py | 3 +- 7 files changed, 140 insertions(+), 86 deletions(-) diff --git a/.gitignore b/.gitignore index c4cdf6dd9..ccb9ae84d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ rixmp/source/.Rhistory # pytest .coverage .pytest_cache/ +*.pid # idea .idea/ diff --git a/ixmp/backend/__init__.py b/ixmp/backend/__init__.py index 903f31b50..9d26466d5 100644 --- a/ixmp/backend/__init__.py +++ b/ixmp/backend/__init__.py @@ -1 +1,6 @@ -from .jdbc import JDBCBackend # noqa: F401 +from .jdbc import JDBCBackend + + +BACKENDS = { + 'jdbc': JDBCBackend, +} diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 07fe18045..b020eeff6 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -1,2 +1,10 @@ -class Backend: - pass +from abc import ABC, abstractmethod + + +class Backend(ABC): + """Abstract base classe for backends.""" + + @abstractmethod + def __init__(self): + """Initialize the backend.""" + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 2ce2088aa..7f47bd3f4 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -1,5 +1,96 @@ -from .base import Backend +import os +from pathlib import Path + +import jpype +from jpype import ( + JPackage as java, +) + +from ixmp.config import _config +from ixmp.utils import logger +from ixmp.backend.base import Backend class JDBCBackend(Backend): - pass + """Backend using JDBC to connect to Oracle and HSQLDB instances. + + Much of the code of this backend is implemented in Java, in the + ixmp_source repository. + """ + #: Reference to the at.ac.iiasa.ixmp.Platform Java object + jobj = None + + def __init__(self, dbprops=None, dbtype=None, jvmargs=None): + start_jvm(jvmargs) + self.dbtype = dbtype + + try: + # if no dbtype is specified, launch Platform with properties file + if dbtype is None: + dbprops = _config.find_dbprops(dbprops) + if dbprops is None: + raise ValueError("Not found database properties file " + "to launch platform") + logger().info("launching ixmp.Platform using config file at " + "'{}'".format(dbprops)) + self.jobj = java.ixmp.Platform("Python", str(dbprops)) + # if dbtype is specified, launch Platform with local database + elif dbtype == 'HSQLDB': + dbprops = dbprops or _config.get('DEFAULT_LOCAL_DB_PATH') + logger().info("launching ixmp.Platform with local {} database " + "at '{}'".format(dbtype, dbprops)) + self.jobj = java.ixmp.Platform("Python", str(dbprops), dbtype) + else: + raise ValueError('Unknown dbtype: {}'.format(dbtype)) + except TypeError: + msg = ("Could not launch the JVM for the ixmp.Platform." + "Make sure that all dependencies of ixmp.jar" + "are included in the 'ixmp/lib' folder.") + logger().info(msg) + raise + + +def start_jvm(jvmargs=None): + """Start the Java Virtual Machine via JPype. + + Parameters + ---------- + jvmargs : str or list of str, optional + Additional arguments to pass to :meth:`jpype.startJVM`. + """ + # TODO change the jvmargs default to [] instead of None + if jpype.isJVMStarted(): + return + + jvmargs = jvmargs or [] + + # Arguments + args = [jpype.getDefaultJVMPath()] + + # Add the ixmp root directory, ixmp.jar and bundled .jar and .dll files to + # the classpath + module_root = Path(__file__).parents[1] + jarfile = module_root / 'ixmp.jar' + module_jars = list(module_root.glob('lib/*')) + classpath = map(str, [module_root, jarfile] + list(module_jars)) + + sep = ';' if os.name == 'nt' else ':' + args.append('-Djava.class.path={}'.format(sep.join(classpath))) + + # Add user args + args.extend(jvmargs if isinstance(jvmargs, list) else [jvmargs]) + + # For JPype 0.7 (raises a warning) and 0.8 (default is False). + # 'True' causes Java string objects to be converted automatically to Python + # str(), as expected by ixmp Python code. + kwargs = dict(convertStrings=True) + + jpype.startJVM(*args, **kwargs) + + # define auxiliary references to Java classes + java.ixmp = java('at.ac.iiasa.ixmp') + java.Integer = java('java.lang').Integer + java.Double = java('java.lang').Double + java.LinkedList = java('java.util').LinkedList + java.HashMap = java('java.util').HashMap + java.LinkedHashMap = java('java.util').LinkedHashMap diff --git a/ixmp/core.py b/ixmp/core.py index 5553d795e..e8717880f 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,11 +1,9 @@ # coding=utf-8 import os import sys -from pathlib import Path from subprocess import check_call import warnings -import jpype from jpype import ( JClass, JPackage as java, @@ -15,8 +13,7 @@ import ixmp as ix from ixmp import model_settings -from .backend import JDBCBackend # noqa: F401 -from ixmp.config import _config +from .backend import BACKENDS from ixmp.utils import logger, islistable, check_year, harmonize_path # %% default settings for column headers @@ -24,52 +21,6 @@ IAMC_IDX = ['model', 'scenario', 'region', 'variable', 'unit'] -def start_jvm(jvmargs=None): - """Start the Java Virtual Machine via JPype. - - Parameters - ---------- - jvmargs : str or list of str, optional - Additional arguments to pass to :meth:`jpype.startJVM`. - """ - # TODO change the jvmargs default to [] instead of None - if jpype.isJVMStarted(): - return - - jvmargs = jvmargs or [] - - # Arguments - args = [jpype.getDefaultJVMPath()] - - # Add the ixmp root directory, ixmp.jar and bundled .jar and .dll files to - # the classpath - module_root = Path(__file__).parent - jarfile = module_root / 'ixmp.jar' - module_jars = list(module_root.glob('lib/*')) - classpath = map(str, [module_root, jarfile] + list(module_jars)) - - sep = ';' if os.name == 'nt' else ':' - args.append('-Djava.class.path={}'.format(sep.join(classpath))) - - # Add user args - args.extend(jvmargs if isinstance(jvmargs, list) else [jvmargs]) - - # For JPype 0.7 (raises a warning) and 0.8 (default is False). - # 'True' causes Java string objects to be converted automatically to Python - # str(), as expected by ixmp Python code. - kwargs = dict(convertStrings=True) - - jpype.startJVM(*args, **kwargs) - - # define auxiliary references to Java classes - java.ixmp = java('at.ac.iiasa.ixmp') - java.Integer = java('java.lang').Integer - java.Double = java('java.lang').Double - java.LinkedList = java('java.util').LinkedList - java.HashMap = java('java.util').HashMap - java.LinkedHashMap = java('java.util').LinkedHashMap - - class Platform: """Database-backed instance of the ixmp. @@ -90,6 +41,13 @@ class Platform: Parameters ---------- + backend : 'jdbc' + Storage backend type. Currently 'jdbc' is the only available backend. + backend_kwargs + Keyword arguments to configure the backend; see below. + + Other parameters + ---------------- dbprops : path-like, optional If `dbtype` is :obj:`None`, the name of a database properties file (default: 'default.properties') in the properties file directory @@ -113,34 +71,23 @@ class Platform: /technotes/tools/windows/java.html) """ - def __init__(self, dbprops=None, dbtype=None, jvmargs=None): - start_jvm(jvmargs) - self.dbtype = dbtype - - try: - # if no dbtype is specified, launch Platform with properties file - if dbtype is None: - dbprops = _config.find_dbprops(dbprops) - if dbprops is None: - raise ValueError("Not found database properties file " - "to launch platform") - logger().info("launching ixmp.Platform using config file at " - "'{}'".format(dbprops)) - self._jobj = java.ixmp.Platform("Python", str(dbprops)) - # if dbtype is specified, launch Platform with local database - elif dbtype == 'HSQLDB': - dbprops = dbprops or _config.get('DEFAULT_LOCAL_DB_PATH') - logger().info("launching ixmp.Platform with local {} database " - "at '{}'".format(dbtype, dbprops)) - self._jobj = java.ixmp.Platform("Python", str(dbprops), dbtype) - else: - raise ValueError('Unknown dbtype: {}'.format(dbtype)) - except TypeError: - msg = ("Could not launch the JVM for the ixmp.Platform." - "Make sure that all dependencies of ixmp.jar" - "are included in the 'ixmp/lib' folder.") - logger().info(msg) - raise + def __init__(self, *args, backend='jdbc', **backend_args): + if backend != 'jdbc': + raise ValueError(f'unknown ixmp backend {backend!r}') + else: + # Copy positional args for the default JDBC backend + print(args, backend_args) + for i, arg in enumerate(['dbprops', 'dbtype', 'jvmargs']): + if len(args) > i: + backend_args[arg] = args[i] + + backend_cls = BACKENDS[backend] + self._be = backend_cls(**backend_args) + + @property + def _jobj(self): + """Shim to allow existing code that references ._jobj to work.""" + return self._be.jobj def set_log_level(self, level): """Set global logger level (for both Python and Java) diff --git a/tests/test_access.py b/tests/test_access.py index c124a20a0..c1ee4ff73 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -1,5 +1,6 @@ from subprocess import Popen from time import sleep +import sys from pretenders.client.http import HTTPMock from pretenders.common.constants import FOREVER @@ -16,8 +17,8 @@ def with_mock_server(test_fn): # def mocked_test(*args, **kwargs): def mocked_test(tmpdir, test_data_path): proc = Popen( - ['python', '-m', 'pretenders.server.server', '--host', '0.0.0.0', - '--port', '8000']) + [sys.executable, '-m', 'pretenders.server.server', '--host', + '0.0.0.0', '--port', '8000']) print('Mock server started with pid ' + str(proc.pid)) sleep(1) try: diff --git a/tests/test_cli.py b/tests/test_cli.py index 8f2a21db1..9689364f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,7 +22,8 @@ def test_jvm_warn(recwarn): """ # Start the JVM for the first time in the test session - ix.start_jvm() + from ixmp.backend.jdbc import start_jvm + start_jvm() if jpype.__version__ > '0.7': # Zero warnings were recorded From 900a9cbaa8043aa25f49357af2cd9565d6c9d8cd Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 12:25:39 +0200 Subject: [PATCH 03/71] Move open_db, close_db, units to Backend --- ixmp/backend/base.py | 29 +++++++++++++++++++++++++ ixmp/backend/jdbc.py | 28 +++++++++++++++++++++++++ ixmp/core.py | 50 ++++++++++++-------------------------------- 3 files changed, 70 insertions(+), 37 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index b020eeff6..e534d8fe9 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -8,3 +8,32 @@ class Backend(ABC): def __init__(self): """Initialize the backend.""" pass + + @abstractmethod + def open_db(self): + """(Re-)open the database connection. + + The database connection is opened automatically for many operations. + After calling :meth:`close_db`, it must be re-opened. + """ + pass + + @abstractmethod + def close_db(self): + """Close the database connection. + + Some backend database connections can only be used by one + :class:`Backend` instance at a time. Any existing connection must be + closed before a new one can be opened. + """ + pass + + @abstractmethod + def units(self): + """Return all units described in the database. + + Returns + ------- + list + """ + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 7f47bd3f4..86ea6fcea 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -5,6 +5,7 @@ from jpype import ( JPackage as java, ) +import numpy as np from ixmp.config import _config from ixmp.utils import logger @@ -49,6 +50,23 @@ def __init__(self, dbprops=None, dbtype=None, jvmargs=None): logger().info(msg) raise + def open_db(self): + """(Re-)open the database connection.""" + self.jobj.openDB() + + def close_db(self): + """Close the database connection. + + A HSQL database can only be used by one :class:`Backend` instance at a + time. Any existing connection must be closed before a new one can be + opened. + """ + self.jobj.closeDB() + + def units(self): + """Return all units described in the database.""" + return to_pylist(self.jobj.getUnitList()) + def start_jvm(jvmargs=None): """Start the Java Virtual Machine via JPype. @@ -94,3 +112,13 @@ def start_jvm(jvmargs=None): java.LinkedList = java('java.util').LinkedList java.HashMap = java('java.util').HashMap java.LinkedHashMap = java('java.util').LinkedHashMap + + +def to_pylist(jlist): + """Transforms a Java.Array or Java.List to a python list""" + # handling string array + try: + return np.array(jlist[:]) + # handling Java LinkedLists + except Exception: + return np.array(jlist.toArray()[:]) diff --git a/ixmp/core.py b/ixmp/core.py index e8717880f..8a83decf2 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -14,6 +14,8 @@ import ixmp as ix from ixmp import model_settings from .backend import BACKENDS +# TODO remove this +from .backend.jdbc import to_pylist from ixmp.utils import logger, islistable, check_year, harmonize_path # %% default settings for column headers @@ -71,6 +73,13 @@ class Platform: /technotes/tools/windows/java.html) """ + # List of method names which are handled directly by the backend + _backend_direct = [ + 'open_db', + 'close_db', + 'units', + ] + def __init__(self, *args, backend='jdbc', **backend_args): if backend != 'jdbc': raise ValueError(f'unknown ixmp backend {backend!r}') @@ -89,6 +98,10 @@ def _jobj(self): """Shim to allow existing code that references ._jobj to work.""" return self._be.jobj + def __getattr__(self, name): + """Convenience for methods that are on Backend.""" + return getattr(self._be, name) + def set_log_level(self, level): """Set global logger level (for both Python and Java) @@ -113,24 +126,6 @@ def set_log_level(self, level): logger().setLevel(level) self._jobj.setLogLevel(py_to_java[level]) - def open_db(self): - """(Re-)open the database connection. - - The database connection is opened automatically for many operations. - After calling :meth:`close_db`, it must be re-opened. - - """ - self._jobj.openDB() - - def close_db(self): - """Close the database connection. - - A HSQL database can only be used by one :class:`Platform` instance at a - time. Any existing connection must be closed before a new one can be - opened. - """ - self._jobj.closeDB() - def scenario_list(self, default=True, model=None, scen=None): """Return information on TimeSeries and Scenarios in the database. @@ -201,15 +196,6 @@ def Scenario(self, model, scen, version=None, return Scenario(self, model, scen, version, scheme, annotation, cache) - def units(self): - """Return all units described in the database. - - Returns - ------- - list - """ - return to_pylist(self._jobj.getUnitList()) - def add_unit(self, unit, comment='None'): """Define a unit. @@ -1536,16 +1522,6 @@ def _jdouble(val): return java.Double(float(val)) -def to_pylist(jlist): - """Transforms a Java.Array or Java.List to a python list""" - # handling string array - try: - return np.array(jlist[:]) - # handling Java LinkedLists - except Exception: - return np.array(jlist.toArray()[:]) - - def to_jlist(pylist, idx_names=None): """Transforms a python list to a Java.LinkedList""" if pylist is None: From 09c8edff627e4fe74adaa9269a1559801a0dfd3a Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 12:32:15 +0200 Subject: [PATCH 04/71] Move set_log_level to Backend --- ixmp/backend/base.py | 5 +++++ ixmp/backend/jdbc.py | 14 ++++++++++++++ ixmp/core.py | 15 ++++----------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index e534d8fe9..a3d47fd6d 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -9,6 +9,11 @@ def __init__(self): """Initialize the backend.""" pass + @abstractmethod + def set_log_level(self, level): + """Set logging level for the backend.""" + pass + @abstractmethod def open_db(self): """(Re-)open the database connection. diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 86ea6fcea..45e82ed69 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -12,6 +12,17 @@ from ixmp.backend.base import Backend +# Map of Python to Java log levels +LOG_LEVELS = { + 'CRITICAL': 'ALL', + 'ERROR': 'ERROR', + 'WARNING': 'WARN', + 'INFO': 'INFO', + 'DEBUG': 'DEBUG', + 'NOTSET': 'OFF', +} + + class JDBCBackend(Backend): """Backend using JDBC to connect to Oracle and HSQLDB instances. @@ -50,6 +61,9 @@ def __init__(self, dbprops=None, dbtype=None, jvmargs=None): logger().info(msg) raise + def set_log_level(self, level): + self._jobj.setLogLevel(LOG_LEVELS[level]) + def open_db(self): """(Re-)open the database connection.""" self.jobj.openDB() diff --git a/ixmp/core.py b/ixmp/core.py index 8a83decf2..b5450af0b 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,4 +1,5 @@ # coding=utf-8 +import logging import os import sys from subprocess import check_call @@ -103,7 +104,7 @@ def __getattr__(self, name): return getattr(self._be, name) def set_log_level(self, level): - """Set global logger level (for both Python and Java) + """Set global logger level. Parameters ---------- @@ -111,20 +112,12 @@ def set_log_level(self, level): set the logger level if specified, see https://docs.python.org/3/library/logging.html#logging-levels """ - py_to_java = { - 'CRITICAL': 'ALL', - 'ERROR': 'ERROR', - 'WARNING': 'WARN', - 'INFO': 'INFO', - 'DEBUG': 'DEBUG', - 'NOTSET': 'OFF', - } - if level not in py_to_java.keys(): + if level not in logging: msg = '{} not a valid Python logger level, see ' + \ 'https://docs.python.org/3/library/logging.html#logging-level' raise ValueError(msg.format(level)) logger().setLevel(level) - self._jobj.setLogLevel(py_to_java[level]) + self._be.set_log_level(level) def scenario_list(self, default=True, model=None, scen=None): """Return information on TimeSeries and Scenarios in the database. From 29ee63676b52e65146a61101e6312edd3a436769 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 13:42:59 +0200 Subject: [PATCH 05/71] Add TimeSeries, Scenario methods to Backend; warnings --- ixmp/backend/base.py | 31 ++++++++++-- ixmp/backend/jdbc.py | 78 +++++++++++++++++++++++++++++- ixmp/core.py | 112 ++++++++++++++++++++++++------------------- 3 files changed, 166 insertions(+), 55 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index a3d47fd6d..9ad7faf57 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -2,9 +2,14 @@ class Backend(ABC): - """Abstract base classe for backends.""" + """Abstract base classe for backends. + + Some methods below are decorated as @abstractmethod; this means they MUST + be overridden by a subclass of Backend. Others that are not decorated + mean that the behaviour here is the default behaviour; subclasses can + leave, replace or extend this behaviour as needed. + """ - @abstractmethod def __init__(self): """Initialize the backend.""" pass @@ -14,7 +19,6 @@ def set_log_level(self, level): """Set logging level for the backend.""" pass - @abstractmethod def open_db(self): """(Re-)open the database connection. @@ -23,7 +27,6 @@ def open_db(self): """ pass - @abstractmethod def close_db(self): """Close the database connection. @@ -42,3 +45,23 @@ def units(self): list """ pass + + @abstractmethod + def ts_init(self, ts, annotation=None): + """Initialize the ixmp.TimeSeries *ts*. + + The method MAY: + + - Modify the version attr of the returned object. + """ + pass + + @abstractmethod + def s_init(self, s, annotation=None): + """Initialize the ixmp.Scenario *s*. + + The method MAY: + + - Modify the version attr of the returned object. + """ + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 45e82ed69..05fc0e9d1 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -3,6 +3,7 @@ import jpype from jpype import ( + JClass, JPackage as java, ) import numpy as np @@ -32,6 +33,10 @@ class JDBCBackend(Backend): #: Reference to the at.ac.iiasa.ixmp.Platform Java object jobj = None + #: Mapping from ixmp.TimeSeries object to the underlying + #: at.ac.iiasa.ixmp.Scenario object (or subclasses of either) + jindex = {} + def __init__(self, dbprops=None, dbtype=None, jvmargs=None): start_jvm(jvmargs) self.dbtype = dbtype @@ -61,8 +66,10 @@ def __init__(self, dbprops=None, dbtype=None, jvmargs=None): logger().info(msg) raise + # Platform methods + def set_log_level(self, level): - self._jobj.setLogLevel(LOG_LEVELS[level]) + self.jobj.setLogLevel(LOG_LEVELS[level]) def open_db(self): """(Re-)open the database connection.""" @@ -81,6 +88,75 @@ def units(self): """Return all units described in the database.""" return to_pylist(self.jobj.getUnitList()) + # Timeseries methods + def ts_init(self, ts, annotation=None): + """Initialize the ixmp.TimeSeries *ts*.""" + if ts.version == 'new': + # Create a new TimeSeries + jobj = self.jobj.newTimeSeries(ts.model, ts.scenario, annotation) + elif isinstance(ts.version, int): + # Load a TimeSeries of specific version + jobj = self.jobj.getTimeSeries(ts.model, ts.scenario, ts.version) + else: + # Load the latest version of a TimeSeries + jobj = self.jobj.getTimeSeries(ts.model, ts.scenario) + + # Update the version attribute + ts.version = jobj.getVersion() + + # Add to index + self.jindex[ts] = jobj + + def ts_discard_changes(self, ts): + """Discard all changes and reload from the database.""" + self.jindex[ts].discardChanges() + + def ts_set_as_default(self, ts): + """Set the current :attr:`version` as the default.""" + self.jindex[ts].setAsDefaultVersion() + + def ts_is_default(self, ts): + """Return :obj:`True` if the :attr:`version` is the default version.""" + return bool(self.jindex[ts].isDefault()) + + def ts_last_update(self, ts): + """get the timestamp of the last update/edit of this TimeSeries""" + return self.jindex[ts].getLastUpdateTimestamp().toString() + + def ts_run_id(self, ts): + """get the run id of this TimeSeries""" + return self.jindex[ts].getRunId() + + def ts_preload(self, ts): + """Preload timeseries data to in-memory cache. Useful for bulk updates. + """ + self.jindex[ts].preloadAllTimeseries() + + # Scenario methods + def s_init(self, s, scheme=None, annotation=None): + """Initialize the ixmp.Scenario *s*.""" + if s.version == 'new': + jobj = self.jobj.newScenario(s.model, s.scenario, scheme, + annotation) + elif isinstance(s.version, int): + jobj = self.jobj.getScenario(s.model, s.scenario, s.version) + # constructor for `message_ix.Scenario.__init__` or `clone()` function + elif isinstance(s.version, + JClass('at.ac.iiasa.ixmp.objects.Scenario')): + jobj = s.version + elif s.version is None: + jobj = self.jobj.getScenario(s.model, s.scenario) + else: + raise ValueError('Invalid `version` arg: `{}`'.format(s.version)) + + s.version = jobj.getVersion() + s.scheme = jobj.getScheme() + + # Add to index + self.jindex[s] = jobj + + + def start_jvm(jvmargs=None): """Start the Java Virtual Machine via JPype. diff --git a/ixmp/core.py b/ixmp/core.py index b5450af0b..5d95aa85d 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,12 +1,14 @@ # coding=utf-8 +import inspect import logging import os import sys from subprocess import check_call import warnings +from warnings import warn +# TODO remove this import from jpype import ( - JClass, JPackage as java, ) import numpy as np @@ -15,8 +17,10 @@ import ixmp as ix from ixmp import model_settings from .backend import BACKENDS -# TODO remove this + +# TODO remove these direct imports of Java-related methods from .backend.jdbc import to_pylist + from ixmp.utils import logger, islistable, check_year, harmonize_path # %% default settings for column headers @@ -92,16 +96,19 @@ def __init__(self, *args, backend='jdbc', **backend_args): backend_args[arg] = args[i] backend_cls = BACKENDS[backend] - self._be = backend_cls(**backend_args) + self._backend = backend_cls(**backend_args) @property def _jobj(self): """Shim to allow existing code that references ._jobj to work.""" - return self._be.jobj + # TODO address all such warnings, then remove + loc = inspect.stack()[1].function + warn(f'Accessing Platform._jobj in {loc}') + return self._backend.jobj def __getattr__(self, name): - """Convenience for methods that are on Backend.""" - return getattr(self._be, name) + """Convenience for methods of Backend.""" + return getattr(self._backend, name) def set_log_level(self, level): """Set global logger level. @@ -112,12 +119,12 @@ def set_log_level(self, level): set the logger level if specified, see https://docs.python.org/3/library/logging.html#logging-levels """ - if level not in logging: + if level not in dir(logging): msg = '{} not a valid Python logger level, see ' + \ 'https://docs.python.org/3/library/logging.html#logging-level' raise ValueError(msg.format(level)) logger().setLevel(level) - self._be.set_log_level(level) + self._backend.set_log_level(level) def scenario_list(self, default=True, model=None, scen=None): """Return information on TimeSeries and Scenarios in the database. @@ -349,25 +356,40 @@ class TimeSeries: annotation : str, optional A short annotation/comment used when ``version='new'``. """ + #: Name of the model associated with the TimeSeries + model = None - # Version of the TimeSeries + #: Name of the scenario associated with the TimeSeries + scenario = None + + #: Version of the TimeSeries. Immutable for a specific instance. version = None def __init__(self, mp, model, scenario, version=None, annotation=None): if not isinstance(mp, Platform): raise ValueError('mp is not a valid `ixmp.Platform` instance') - if version == 'new': - self._jobj = mp._jobj.newTimeSeries(model, scenario, annotation) - elif isinstance(version, int): - self._jobj = mp._jobj.getTimeSeries(model, scenario, version) - else: - self._jobj = mp._jobj.getTimeSeries(model, scenario) - - self.platform = mp + # Set attributes self.model = model self.scenario = scenario - self.version = self._jobj.getVersion() + self.version = version + + # All the backend to complete initialization + self.platform = mp + self._backend('init', annotation) + + @property + def _jobj(self): + """Shim to allow existing code that references ._jobj to work.""" + # TODO address all such warnings, then remove + loc = inspect.stack()[1].function + warn(f'Accessing {self.__class__.__name__}._jobj in {loc}') + return self.platform._backend.jindex[self] + + def _backend(self, method, *args, **kwargs): + """Convenience for calling *method* on the backend.""" + func = getattr(self.platform._backend, f'ts_{method}') + return func(self, *args, **kwargs) # functions for platform management @@ -393,34 +415,30 @@ def commit(self, comment): def discard_changes(self): """Discard all changes and reload from the database.""" - self._jobj.discardChanges() + self._backend('discard_changes') def set_as_default(self): """Set the current :attr:`version` as the default.""" - self._jobj.setAsDefaultVersion() + self._backend('set_as_default') def is_default(self): """Return :obj:`True` if the :attr:`version` is the default version.""" - return bool(self._jobj.isDefault()) + return self._backend('is_default') def last_update(self): """get the timestamp of the last update/edit of this TimeSeries""" - return self._jobj.getLastUpdateTimestamp().toString() + return self._backend('last_update') def run_id(self): """get the run id of this TimeSeries""" - return self._jobj.getRunId() - - def version(self): - """get the version number of this TimeSeries""" - return self._jobj.getVersion() + return self._backend('run_id') # functions for importing and retrieving timeseries data def preload_timeseries(self): """Preload timeseries data to in-memory cache. Useful for bulk updates. """ - self._jobj.preloadAllTimeseries() + self._backend('preload') def add_timeseries(self, df, meta=False): """Add data to the TimeSeries. @@ -639,12 +657,6 @@ class Scenario(TimeSeries): Store data in memory and return cached values instead of repeatedly querying the database. """ - # Name of the model associated with the Scenario - model = None - - # Name of the Scenario - scenario = None - _java_kwargs = { 'set': {}, 'par': {'has_value': True}, @@ -657,31 +669,31 @@ def __init__(self, mp, model, scenario, version=None, scheme=None, if not isinstance(mp, Platform): raise ValueError('mp is not a valid `ixmp.Platform` instance') - if version == 'new': - self._jobj = mp._jobj.newScenario(model, scenario, scheme, - annotation) - elif isinstance(version, int): - self._jobj = mp._jobj.getScenario(model, scenario, version) - # constructor for `message_ix.Scenario.__init__` or `clone()` function - elif isinstance(version, JClass('at.ac.iiasa.ixmp.objects.Scenario')): - self._jobj = version - elif version is None: - self._jobj = mp._jobj.getScenario(model, scenario) - else: - raise ValueError('Invalid `version` arg: `{}`'.format(version)) - - self.platform = mp + # Set attributes self.model = model self.scenario = scenario - self.version = self._jobj.getVersion() - self.scheme = scheme or self._jobj.getScheme() + self.version = version + + # All the backend to complete initialization + self.platform = mp + self._backend('init', scheme, annotation) + if self.scheme == 'MESSAGE' and not hasattr(self, 'is_message_scheme'): warnings.warn('Using `ixmp.Scenario` for MESSAGE-scheme scenarios ' 'is deprecated, please use `message_ix.Scenario`') + # Initialize cache self._cache = cache self._pycache = {} + def _backend(self, method, *args, **kwargs): + """Convenience for calling *method* on the backend.""" + try: + func = getattr(self.platform._backend, f's_{method}') + except AttributeError: + func = getattr(self.platform._backend, f'ts_{method}') + return func(self, *args, **kwargs) + def _item(self, ix_type, name, load=True): """Return the Java object for item *name* of *ix_type*. From 8843ef623aaa2b3b80ddb5f7bcdc3094deda125c Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 13:48:22 +0200 Subject: [PATCH 06/71] Move Java/Python conversions to backend.jdbc --- ixmp/backend/jdbc.py | 29 ++++++++++++++++++++++++++--- ixmp/core.py | 32 ++++++-------------------------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 05fc0e9d1..f46d1c018 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -9,7 +9,7 @@ import numpy as np from ixmp.config import _config -from ixmp.utils import logger +from ixmp.utils import islistable, logger from ixmp.backend.base import Backend @@ -156,8 +156,6 @@ def s_init(self, s, scheme=None, annotation=None): self.jindex[s] = jobj - - def start_jvm(jvmargs=None): """Start the Java Virtual Machine via JPype. @@ -204,6 +202,8 @@ def start_jvm(jvmargs=None): java.LinkedHashMap = java('java.util').LinkedHashMap +# Conversion methods + def to_pylist(jlist): """Transforms a Java.Array or Java.List to a python list""" # handling string array @@ -212,3 +212,26 @@ def to_pylist(jlist): # handling Java LinkedLists except Exception: return np.array(jlist.toArray()[:]) + + +def to_jdouble(val): + """Returns a Java.Double""" + return java.Double(float(val)) + + +def to_jlist(pylist, idx_names=None): + """Transforms a python list to a Java.LinkedList""" + if pylist is None: + return None + + jList = java.LinkedList() + if idx_names is None: + if islistable(pylist): + for key in pylist: + jList.add(str(key)) + else: + jList.add(str(pylist)) + else: + for idx in idx_names: + jList.add(str(pylist[idx])) + return jList diff --git a/ixmp/core.py b/ixmp/core.py index 5d95aa85d..939383f00 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -19,9 +19,12 @@ from .backend import BACKENDS # TODO remove these direct imports of Java-related methods -from .backend.jdbc import to_pylist - -from ixmp.utils import logger, islistable, check_year, harmonize_path +from .backend.jdbc import ( + to_jdouble as _jdouble, + to_jlist, + to_pylist, +) +from ixmp.utils import logger, check_year, harmonize_path # %% default settings for column headers @@ -1522,29 +1525,6 @@ def filtered(df, filters): return df[mask] -def _jdouble(val): - """Returns a Java.Double""" - return java.Double(float(val)) - - -def to_jlist(pylist, idx_names=None): - """Transforms a python list to a Java.LinkedList""" - if pylist is None: - return None - - jList = java.LinkedList() - if idx_names is None: - if islistable(pylist): - for key in pylist: - jList.add(str(key)) - else: - jList.add(str(pylist)) - else: - for idx in idx_names: - jList.add(str(pylist[idx])) - return jList - - def to_iamc_template(df): """Formats a pd.DataFrame to an IAMC-compatible table""" if "time" in df.columns: From 514065c049be089a9f6ff5140666be4bb525d1de Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 14:48:49 +0200 Subject: [PATCH 07/71] Add 4 methods to Backend - s_has_solution - s_list_items - s_item_index - s_item_elements --- ixmp/__init__.py | 26 ++++---- ixmp/backend/base.py | 44 ++++++++++++- ixmp/backend/jdbc.py | 106 +++++++++++++++++++++++++++++++ ixmp/core.py | 144 +++++++++---------------------------------- 4 files changed, 189 insertions(+), 131 deletions(-) diff --git a/ixmp/__init__.py b/ixmp/__init__.py index 1936d1389..7c4391cf7 100644 --- a/ixmp/__init__.py +++ b/ixmp/__init__.py @@ -1,25 +1,21 @@ -import sys - from ._version import get_versions __version__ = get_versions()['version'] del get_versions -from ixmp.core import * - -from ixmp import ( - model_settings, - utils, +from ixmp.core import ( # noqa: E402,F401 + Platform, + TimeSeries, + Scenario, ) - -if sys.version_info[0] == 3: - from ixmp.reporting import Reporter # noqa: F401 +from ixmp.model_settings import ModelConfig, register_model # noqa: E402 +from ixmp.reporting import Reporter # noqa: F401 -model_settings.register_model( +register_model( 'default', - model_settings.ModelConfig(model_file='"{model}.gms"', - inp='{model}_in.gdx', - outp='{model}_out.gdx', - args=['--in="{inp}"', '--out="{outp}"']) + ModelConfig(model_file='"{model}.gms"', + inp='{model}_in.gdx', + outp='{model}_out.gdx', + args=['--in="{inp}"', '--out="{outp}"']) ) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 9ad7faf57..7f19f4035 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -2,12 +2,13 @@ class Backend(ABC): - """Abstract base classe for backends. + """Abstract base class for backends. Some methods below are decorated as @abstractmethod; this means they MUST be overridden by a subclass of Backend. Others that are not decorated - mean that the behaviour here is the default behaviour; subclasses can + mean that the behaviour here is the default behaviour; subclasses MAY leave, replace or extend this behaviour as needed. + """ def __init__(self): @@ -65,3 +66,42 @@ def s_init(self, s, annotation=None): - Modify the version attr of the returned object. """ pass + + @abstractmethod + def s_has_solution(self): + """Return :obj:`True` if the Scenario has been solved. + + If :obj:`True`, model solution data exists in the database. + """ + pass + + @abstractmethod + def s_list_items(self, s, type): + """Return a list of items of *type* in the Scenario *s*.""" + pass + + @abstractmethod + def s_item_index(self, s, name, type): + """Return the index sets or names of item *name*. + + Parameters + ---------- + type : 'set' or 'name' + """ + pass + + @abstractmethod + def s_item_elements(self, s, type, name, filters=None, has_value=False, + has_level=False): + """Return elements of item *name* in Scenario *s*. + + The return type varies according to the *type* and contents: + + - Scalars vs. parameters. + - Lists, e.g. set elements. + - Mapping sets. + - Multi-dimensional parameters, equations, or variables. + """ + # TODO exactly specify the return types in the docstring using MUST, + # MAY, etc. terms + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index f46d1c018..a71037232 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -7,6 +7,7 @@ JPackage as java, ) import numpy as np +import pandas as pd from ixmp.config import _config from ixmp.utils import islistable, logger @@ -89,6 +90,7 @@ def units(self): return to_pylist(self.jobj.getUnitList()) # Timeseries methods + def ts_init(self, ts, annotation=None): """Initialize the ixmp.TimeSeries *ts*.""" if ts.version == 'new': @@ -133,6 +135,7 @@ def ts_preload(self, ts): self.jindex[ts].preloadAllTimeseries() # Scenario methods + def s_init(self, s, scheme=None, annotation=None): """Initialize the ixmp.Scenario *s*.""" if s.version == 'new': @@ -155,6 +158,94 @@ def s_init(self, s, scheme=None, annotation=None): # Add to index self.jindex[s] = jobj + def s_has_solution(self, s): + return self.jindex[s].hasSolution() + + def s_list_items(self, s, type): + return to_pylist(getattr(self.jindex[s], f'get{type.title()}List')()) + + def s_item_index(self, s, name, type): + jitem = self._get_item(s, 'item', name) + return to_pylist(getattr(jitem, f'getIdx{type.title()}')()) + + def s_item_elements(self, s, type, name, filters=None, has_value=False, + has_level=False): + # Retrieve the item + item = self._get_item(s, type, name, load=True) + + # get list of elements, with filter HashMap if provided + if filters is not None: + jFilter = java.HashMap() + for idx_name in filters.keys(): + jFilter.put(idx_name, to_jlist(filters[idx_name])) + jList = item.getElements(jFilter) + else: + jList = item.getElements() + + # return a dataframe if this is a mapping or multi-dimensional + # parameter + dim = item.getDim() + if dim > 0: + idx_names = np.array(item.getIdxNames().toArray()[:]) + idx_sets = np.array(item.getIdxSets().toArray()[:]) + + data = {} + for d in range(dim): + ary = np.array(item.getCol(d, jList)[:]) + if idx_sets[d] == "year": + # numpy tricks to avoid extra copy + # _ary = ary.view('int') + # _ary[:] = ary + ary = ary.astype('int') + data[idx_names[d]] = ary + + if has_value: + data['value'] = np.array(item.getValues(jList)[:]) + data['unit'] = np.array(item.getUnits(jList)[:]) + + if has_level: + data['lvl'] = np.array(item.getLevels(jList)[:]) + data['mrg'] = np.array(item.getMarginals(jList)[:]) + + df = pd.DataFrame.from_dict(data, orient='columns', dtype=None) + return df + + else: + # for index sets + if not (has_value or has_level): + return pd.Series(item.getCol(0, jList)[:]) + + data = {} + + # for parameters as scalars + if has_value: + data['value'] = item.getScalarValue().floatValue() + data['unit'] = str(item.getScalarUnit()) + + # for variables as scalars + elif has_level: + data['lvl'] = item.getScalarLevel().floatValue() + data['mrg'] = item.getScalarMarginal().floatValue() + + return data + + # Helpers; not part of the Backend interface + + def _get_item(self, s, ix_type, name, load=True): + """Return the Java object for item *name* of *ix_type*. + + Parameters + ---------- + load : bool, optional + If *ix_type* is 'par', 'var', or 'equ', the elements of the item + are loaded from the database before :meth:`_item` returns. If + :const:`False`, the elements can be loaded later using + ``item.loadItemElementsfromDB()``. + """ + # getItem is not overloaded to accept a second bool argument + args = [name] + ([load] if ix_type != 'item' else []) + return getattr(self.jindex[s], f'get{ix_type.title()}')(*args) + def start_jvm(jvmargs=None): """Start the Java Virtual Machine via JPype. @@ -235,3 +326,18 @@ def to_jlist(pylist, idx_names=None): for idx in idx_names: jList.add(str(pylist[idx])) return jList + + +# Helper methods + + +def filtered(df, filters): + """Returns a filtered dataframe based on a filters dictionary""" + if filters is None: + return df + + mask = pd.Series(True, index=df.index) + for k, v in filters.items(): + isin = df[k].isin(v) + mask = mask & isin + return df[mask] diff --git a/ixmp/core.py b/ixmp/core.py index 939383f00..3d2643bfc 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -14,7 +14,6 @@ import numpy as np import pandas as pd -import ixmp as ix from ixmp import model_settings from .backend import BACKENDS @@ -23,8 +22,9 @@ to_jdouble as _jdouble, to_jlist, to_pylist, + filtered, ) -from ixmp.utils import logger, check_year, harmonize_path +from ixmp.utils import logger, check_year, harmonize_path, numcols # %% default settings for column headers @@ -503,7 +503,7 @@ def add_timeseries(self, df, meta=False): for i in df.index: jData = java.LinkedHashMap() - for j in ix.utils.numcols(df): + for j in numcols(df): jData.put(java.Integer(int(j)), java.Double(float(df[j][i]))) @@ -535,10 +535,10 @@ def timeseries(self, iamc=False, region=None, variable=None, level=None, Specified data. """ # convert filter lists to Java objects - region = ix.to_jlist(region) - variable = ix.to_jlist(variable) - unit = ix.to_jlist(unit) - year = ix.to_jlist(year) + region = to_jlist(region) + variable = to_jlist(variable) + unit = to_jlist(unit) + year = to_jlist(year) # retrieve data, convert to pandas.DataFrame data = self._jobj.getTimeseries(region, variable, unit, None, year) @@ -698,26 +698,11 @@ def _backend(self, method, *args, **kwargs): return func(self, *args, **kwargs) def _item(self, ix_type, name, load=True): - """Return the Java object for item *name* of *ix_type*. - - Parameters - ---------- - load : bool, optional - If *ix_type* is 'par', 'var', or 'equ', the elements of the item - are loaded from the database before :meth:`_item` returns. If - :const:`False`, the elements can be loaded later using - ``item.loadItemElementsfromDB()``. - """ - funcs = { - 'item': self._jobj.getItem, - 'set': self._jobj.getSet, - 'par': self._jobj.getPar, - 'var': self._jobj.getVar, - 'equ': self._jobj.getEqu, - } - # getItem is not overloaded to accept a second bool argument - args = [name] + ([load] if ix_type != 'item' else []) - return funcs[ix_type](*args) + """Shim to allow existing code that references ._item to work.""" + # TODO address all such warnings, then remove + loc = inspect.stack()[1].function + warn(f'Calling {self.__class__.__name__}._item() in {loc}') + return self.platform._backend._get_item(self, ix_type, name) def load_scenario_data(self): """Load all Scenario data into memory. @@ -743,7 +728,6 @@ def load_scenario_data(self): def _element(self, ix_type, name, filters=None, cache=None): """Return a pd.DataFrame of item elements.""" - item = self._item(ix_type, name) cache_key = (ix_type, name) # if dataframe in python cache, retrieve from there @@ -752,10 +736,12 @@ def _element(self, ix_type, name, filters=None, cache=None): # if no cache, retrieve from Java with filters if filters is not None and not self._cache: - return _get_ele_list(item, filters, **self._java_kwargs[ix_type]) + return self._backend('item_elements', ix_type, name, filters, + **self._java_kwargs[ix_type]) # otherwise, retrieve from Java and keep in python cache - df = _get_ele_list(item, None, **self._java_kwargs[ix_type]) + df = self._backend('item_elements', ix_type, name, None, + **self._java_kwargs[ix_type]) # save if using memcache if self._cache: @@ -771,7 +757,7 @@ def idx_sets(self, name): name : str name of the item """ - return to_pylist(self._item('item', name).getIdxSets()) + return self._backend('item_index', name, 'sets') def idx_names(self, name): """return the list of index names for an item (set, par, var, equ) @@ -781,7 +767,7 @@ def idx_names(self, name): name : str name of the item """ - return to_pylist(self._item('item', name).getIdxNames()) + return self._backend('item_index', name, 'names') def cat_list(self, name): raise DeprecationWarning('function was migrated to `message_ix` class') @@ -794,11 +780,11 @@ def cat(self, name, cat): def set_list(self): """List all defined sets.""" - return to_pylist(self._jobj.getSetList()) + return self._backend('list_items', 'set') def has_set(self, name): - """check whether the scenario has a set with that name""" - return self._jobj.hasSet(name) + """Check whether the scenario has a set *name*.""" + return name in self.set_list() def init_set(self, name, idx_sets=None, idx_names=None): """Initialize a new set. @@ -918,11 +904,11 @@ def remove_set(self, name, key=None): def par_list(self): """List all defined parameters.""" - return to_pylist(self._jobj.getParList()) + return self._backend('list_items', 'par') def has_par(self, name): """check whether the scenario has a parameter with that name""" - return self._jobj.hasPar(name) + return name in self.par_list() def init_par(self, name, idx_sets, idx_names=None): """Initialize a new parameter. @@ -1054,7 +1040,8 @@ def scalar(self, name): ------- {'value': value, 'unit': unit} """ - return _get_ele_list(self._jobj.getPar(name), None, has_value=True) + return self._backend('item_elements', 'par', name, None, + has_value=True) def change_scalar(self, name, val, unit, comment=None): """Set the value and unit of a scalar. @@ -1092,11 +1079,11 @@ def remove_par(self, name, key=None): def var_list(self): """List all defined variables.""" - return to_pylist(self._jobj.getVarList()) + return self._backend('list_items', 'var') def has_var(self, name): """check whether the scenario has a variable with that name""" - return self._jobj.hasVar(name) + return name in self.var_list() def init_var(self, name, idx_sets=None, idx_names=None): """initialize a new variable in the scenario @@ -1126,7 +1113,7 @@ def var(self, name, filters=None, **kwargs): def equ_list(self): """List all defined equations.""" - return to_pylist(self._jobj.getEquList()) + return self._backend('list_items', 'equ') def init_equ(self, name, idx_sets=None, idx_names=None): """Initialize a new equation. @@ -1144,7 +1131,7 @@ def init_equ(self, name, idx_sets=None, idx_names=None): def has_equ(self, name): """check whether the scenario has an equation with that name""" - return self._jobj.hasEqu(name) + return name in self.equ_list() def equ(self, name, filters=None, **kwargs): """return a dataframe of (filtered) elements for a specific equation @@ -1270,7 +1257,7 @@ def has_solution(self): If ``has_solution() == True``, model solution data exists in the db. """ - return self._jobj.hasSolution() + return self._backend('has_solution') def remove_solution(self, first_model_year=None): """Remove the solution from the scenario @@ -1512,19 +1499,6 @@ def set_meta(self, name, value): # %% auxiliary functions for class Scenario - -def filtered(df, filters): - """Returns a filtered dataframe based on a filters dictionary""" - if filters is None: - return df - - mask = pd.Series(True, index=df.index) - for k, v in filters.items(): - isin = df[k].isin(v) - mask = mask & isin - return df[mask] - - def to_iamc_template(df): """Formats a pd.DataFrame to an IAMC-compatible table""" if "time" in df.columns: @@ -1553,64 +1527,6 @@ def make_dims(sets, names): return to_jlist(sets), to_jlist(names if names is not None else sets) -def _get_ele_list(item, filters=None, has_value=False, has_level=False): - - # get list of elements, with filter HashMap if provided - if filters is not None: - jFilter = java.HashMap() - for idx_name in filters.keys(): - jFilter.put(idx_name, to_jlist(filters[idx_name])) - jList = item.getElements(jFilter) - else: - jList = item.getElements() - - # return a dataframe if this is a mapping or multi-dimensional parameter - dim = item.getDim() - if dim > 0: - idx_names = np.array(item.getIdxNames().toArray()[:]) - idx_sets = np.array(item.getIdxSets().toArray()[:]) - - data = {} - for d in range(dim): - ary = np.array(item.getCol(d, jList)[:]) - if idx_sets[d] == "year": - # numpy tricks to avoid extra copy - # _ary = ary.view('int') - # _ary[:] = ary - ary = ary.astype('int') - data[idx_names[d]] = ary - - if has_value: - data['value'] = np.array(item.getValues(jList)[:]) - data['unit'] = np.array(item.getUnits(jList)[:]) - - if has_level: - data['lvl'] = np.array(item.getLevels(jList)[:]) - data['mrg'] = np.array(item.getMarginals(jList)[:]) - - df = pd.DataFrame.from_dict(data, orient='columns', dtype=None) - return df - - else: - # for index sets - if not (has_value or has_level): - return pd.Series(item.getCol(0, jList)[:]) - - data = {} - - # for parameters as scalars - if has_value: - data['value'] = item.getScalarValue().floatValue() - data['unit'] = str(item.getScalarUnit()) - - # for variables as scalars - elif has_level: - data['lvl'] = item.getScalarLevel().floatValue() - data['mrg'] = item.getScalarMarginal().floatValue() - - return data - - def _remove_ele(item, key): """auxiliary """ if item.getDim() > 0: From f5217fd1232572459b5ad6aa292cec9ba62fc7de Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2019 15:03:43 +0200 Subject: [PATCH 08/71] Add s_init_item to Backend --- ixmp/backend/base.py | 9 +++++++-- ixmp/backend/jdbc.py | 18 ++++++++++++++++-- ixmp/core.py | 18 ++++++------------ 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 7f19f4035..4ae98e756 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -81,12 +81,17 @@ def s_list_items(self, s, type): pass @abstractmethod - def s_item_index(self, s, name, type): + def s_init_item(self, s, type, name): + """Initialize or create a new item *name* of *type* in Scenario *s*.""" + pass + + @abstractmethod + def s_item_index(self, s, name, sets_or_names): """Return the index sets or names of item *name*. Parameters ---------- - type : 'set' or 'name' + sets_or_names : 'sets' or 'names' """ pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index a71037232..87893ce43 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -164,9 +164,23 @@ def s_has_solution(self, s): def s_list_items(self, s, type): return to_pylist(getattr(self.jindex[s], f'get{type.title()}List')()) - def s_item_index(self, s, name, type): + def s_init_item(self, s, type, name, idx_sets, idx_names): + # generate index-set and index-name lists + if isinstance(idx_sets, set) or isinstance(idx_names, set): + raise ValueError('index dimension must be string or ordered lists') + idx_sets = to_jlist(idx_sets) + idx_names = to_jlist(idx_names if idx_names is not None else idx_sets) + + # Initialize the Item + func = getattr(self.jindex[s], f'initialize{type.title()}') + + # The constructor returns a reference to the Java Item, but these + # aren't exposed by Backend, so don't return here + func(name, idx_sets, idx_names) + + def s_item_index(self, s, name, sets_or_names): jitem = self._get_item(s, 'item', name) - return to_pylist(getattr(jitem, f'getIdx{type.title()}')()) + return to_pylist(getattr(jitem, f'getIdx{sets_or_names.title()}')()) def s_item_elements(self, s, type, name, filters=None, has_value=False, has_level=False): diff --git a/ixmp/core.py b/ixmp/core.py index 3d2643bfc..412a2c9b2 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -803,7 +803,7 @@ def init_set(self, name, idx_sets=None, idx_names=None): :class:`jpype.JavaException` If the set (or another object with the same *name*) already exists. """ - self._jobj.initializeSet(name, *make_dims(idx_sets, idx_names)) + return self._backend('init_item', 'set', name, idx_sets, idx_names) def set(self, name, filters=None, **kwargs): """Return the (filtered) elements of a set. @@ -922,7 +922,7 @@ def init_par(self, name, idx_sets, idx_names=None): idx_names : list of str, optional Names of the dimensions indexed by `idx_sets`. """ - self._jobj.initializePar(name, *make_dims(idx_sets, idx_names)) + return self._backend('init_item', 'par', name, idx_sets, idx_names) def par(self, name, filters=None, **kwargs): """return a dataframe of (filtered) elements for a specific parameter @@ -1025,7 +1025,8 @@ def init_scalar(self, name, val, unit, comment=None): comment : str, optional Description of the scalar. """ - jPar = self._jobj.initializePar(name, None, None) + self.init_par(name, None, None) + jPar = self._item('par', name) jPar.addElement(_jdouble(val), unit, comment) def scalar(self, name): @@ -1097,7 +1098,7 @@ def init_var(self, name, idx_sets=None, idx_names=None): idx_names : list of str, optional index name list """ - self._jobj.initializeVar(name, *make_dims(idx_sets, idx_names)) + return self._backend('init_item', 'var', name, idx_sets, idx_names) def var(self, name, filters=None, **kwargs): """return a dataframe of (filtered) elements for a specific variable @@ -1127,7 +1128,7 @@ def init_equ(self, name, idx_sets=None, idx_names=None): idx_names : list of str, optional index name list """ - self._jobj.initializeEqu(name, *make_dims(idx_sets, idx_names)) + return self._backend('init_item', 'equ', name, idx_sets, idx_names) def has_equ(self, name): """check whether the scenario has an equation with that name""" @@ -1520,13 +1521,6 @@ def to_iamc_template(df): return df -def make_dims(sets, names): - """Wrapper of `to_jlist()` to generate an index-name and index-set list""" - if isinstance(sets, set) or isinstance(names, set): - raise ValueError('index dimension must be string or ordered lists!') - return to_jlist(sets), to_jlist(names if names is not None else sets) - - def _remove_ele(item, key): """auxiliary """ if item.getDim() > 0: From 09a4d2eefc88d1c7a517f64f778bdd3842e93d12 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 15:01:17 +0200 Subject: [PATCH 09/71] Remove xarray version spec of <0.12 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5941eda91..6f7fdfe22 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ 'pandas', 'pint', 'PyYAML', - 'xarray<0.12', + 'xarray', 'xlsxwriter', 'xlrd', ] From 4e197f88b2f6644d746e426a6ca6c9a92e6a9136 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 15:38:42 +0200 Subject: [PATCH 10/71] Add s_add_set_elements to Backend, rewrite add_set, expand tests --- ixmp/backend/base.py | 22 +++++++ ixmp/backend/jdbc.py | 86 +++++++++++++++++++----- ixmp/core.py | 153 ++++++++++++++++++++++++++++--------------- ixmp/utils.py | 21 ++++++ tests/test_core.py | 59 ++++++++++++++++- 5 files changed, 272 insertions(+), 69 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 4ae98e756..a558f5667 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -110,3 +110,25 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, # TODO exactly specify the return types in the docstring using MUST, # MAY, etc. terms pass + + @abstractmethod + def s_add_set_elements(self, s, name, elements): + """Add elements to set *name* in Scenario *s*. + + Parameters + ---------- + elements : list of 2-tuples + The first element of each tuple is a key (str or list of str). + The number and order of key dimensions must match the index of + *name*, if any. The second element is a str comment describing the + key, or None. + + Raises + ------ + ValueError + If *elements* contain invalid values, e.g. for an indexed set, + values not in the index set(s). + Exception + If the Backend encounters any error adding the key. + """ + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 87893ce43..142218bc7 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -1,10 +1,15 @@ import os from pathlib import Path +import re import jpype from jpype import ( JClass, - JPackage as java, + JPackage, +) +from jpype.types import ( # noqa: F401 + JInt, + JDouble, ) import numpy as np import pandas as pd @@ -24,12 +29,25 @@ 'NOTSET': 'OFF', } +# Java packages, loaded by start_jvm() +jixmp = None # corresponds to at.ac.iiasa.ixmp + +# Java classes, loaded by start_jvm() +JLinkedList = None +JHashMap = None +JLinkedHashMap = None + class JDBCBackend(Backend): """Backend using JDBC to connect to Oracle and HSQLDB instances. Much of the code of this backend is implemented in Java, in the ixmp_source repository. + + Among other things, this backend: + - Catches Java exceptions such as ixmp.exceptions.IxException, and + re-raises them as appropriate Python exceptions. + """ #: Reference to the at.ac.iiasa.ixmp.Platform Java object jobj = None @@ -51,13 +69,13 @@ def __init__(self, dbprops=None, dbtype=None, jvmargs=None): "to launch platform") logger().info("launching ixmp.Platform using config file at " "'{}'".format(dbprops)) - self.jobj = java.ixmp.Platform("Python", str(dbprops)) + self.jobj = jixmp.Platform('Python', str(dbprops)) # if dbtype is specified, launch Platform with local database elif dbtype == 'HSQLDB': dbprops = dbprops or _config.get('DEFAULT_LOCAL_DB_PATH') logger().info("launching ixmp.Platform with local {} database " "at '{}'".format(dbtype, dbprops)) - self.jobj = java.ixmp.Platform("Python", str(dbprops), dbtype) + self.jobj = jixmp.Platform('Python', str(dbprops), dbtype) else: raise ValueError('Unknown dbtype: {}'.format(dbtype)) except TypeError: @@ -180,7 +198,7 @@ def s_init_item(self, s, type, name, idx_sets, idx_names): def s_item_index(self, s, name, sets_or_names): jitem = self._get_item(s, 'item', name) - return to_pylist(getattr(jitem, f'getIdx{sets_or_names.title()}')()) + return list(getattr(jitem, f'getIdx{sets_or_names.title()}')()) def s_item_elements(self, s, type, name, filters=None, has_value=False, has_level=False): @@ -189,7 +207,7 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, # get list of elements, with filter HashMap if provided if filters is not None: - jFilter = java.HashMap() + jFilter = JHashMap() for idx_name in filters.keys(): jFilter.put(idx_name, to_jlist(filters[idx_name])) jList = item.getElements(jFilter) @@ -243,6 +261,28 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, return data + def s_add_set_elements(self, s, name, elements): + """Add elements to set *name* in Scenario *s*.""" + # Retrieve the Java Set and its number of dimensions + jSet = self._get_item(s, 'set', name) + dim = jSet.getDim() + + try: + for e, comment in elements: + if dim: + # Convert e to a JLinkedList + e = to_jlist2(e) + + # Call with 1 or 2 args + jSet.addElement(e, comment) if comment else jSet.addElement(e) + except jixmp.exceptions.IxException as e: + msg = e.message() + if 'does not have an element' in msg: + # Re-raise as Python ValueError + raise ValueError(msg) from e + else: + raise RuntimeError('Unhandled Java exception') from e + # Helpers; not part of the Backend interface def _get_item(self, s, ix_type, name, load=True): @@ -258,7 +298,15 @@ def _get_item(self, s, ix_type, name, load=True): """ # getItem is not overloaded to accept a second bool argument args = [name] + ([load] if ix_type != 'item' else []) - return getattr(self.jindex[s], f'get{ix_type.title()}')(*args) + try: + return getattr(self.jindex[s], f'get{ix_type.title()}')(*args) + except jixmp.exceptions.IxException as e: + if re.match('No item [^ ]* exists in this Scenario', e.args[0]): + # Re-raise as a Python KeyError + raise KeyError(f'No {ix_type.title()} {name!r} exists in this ' + 'Scenario!') from None + else: + raise RuntimeError('Unhandled Java exception') from e def start_jvm(jvmargs=None): @@ -298,13 +346,13 @@ def start_jvm(jvmargs=None): jpype.startJVM(*args, **kwargs) + global JLinkedList, JHashMap, JLinkedHashMap, jixmp + # define auxiliary references to Java classes - java.ixmp = java('at.ac.iiasa.ixmp') - java.Integer = java('java.lang').Integer - java.Double = java('java.lang').Double - java.LinkedList = java('java.util').LinkedList - java.HashMap = java('java.util').HashMap - java.LinkedHashMap = java('java.util').LinkedHashMap + jixmp = JPackage('at.ac.iiasa.ixmp') + JLinkedList = JClass('java.util.LinkedList') + JHashMap = JClass('java.util.HashMap') + JLinkedHashMap = JClass('java.util.LinkedHashMap') # Conversion methods @@ -321,15 +369,15 @@ def to_pylist(jlist): def to_jdouble(val): """Returns a Java.Double""" - return java.Double(float(val)) + return JDouble(float(val)) def to_jlist(pylist, idx_names=None): - """Transforms a python list to a Java.LinkedList""" + """Convert *pylist* to a jLinkedList.""" if pylist is None: return None - jList = java.LinkedList() + jList = JLinkedList() if idx_names is None: if islistable(pylist): for key in pylist: @@ -337,11 +385,19 @@ def to_jlist(pylist, idx_names=None): else: jList.add(str(pylist)) else: + # pylist must be a dict for idx in idx_names: jList.add(str(pylist[idx])) return jList +def to_jlist2(arg): + """Simple conversion of :class:`list` *arg* to JLinkedList.""" + jlist = JLinkedList() + jlist.addAll(arg) + return jlist + + # Helper methods diff --git a/ixmp/core.py b/ixmp/core.py index 412a2c9b2..6e5f3fc39 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,5 +1,6 @@ # coding=utf-8 import inspect +from itertools import repeat, zip_longest import logging import os import sys @@ -7,10 +8,6 @@ import warnings from warnings import warn -# TODO remove this import -from jpype import ( - JPackage as java, -) import numpy as np import pandas as pd @@ -19,12 +16,22 @@ # TODO remove these direct imports of Java-related methods from .backend.jdbc import ( + JLinkedList, + JLinkedHashMap, + JInt, + JDouble, to_jdouble as _jdouble, to_jlist, to_pylist, filtered, ) -from ixmp.utils import logger, check_year, harmonize_path, numcols +from ixmp.utils import ( + as_str_list, + check_year, + harmonize_path, + logger, + numcols, +) # %% default settings for column headers @@ -291,7 +298,7 @@ def check_access(self, user, models, access='view'): if isinstance(models, str): return self._jobj.checkModelAccess(user, access, models) else: - models_list = java.LinkedList() + models_list = JLinkedList() for model in models: models_list.add(model) access_map = self._jobj.checkModelAccess(user, access, models_list) @@ -478,7 +485,7 @@ def add_timeseries(self, df, meta=False): variable = df.variable[0] unit = df.unit[0] time = None - jData = java.LinkedHashMap() + jData = JLinkedHashMap() for i in df.index: if not (region == df.region[i] and variable == df.variable[i] @@ -491,21 +498,21 @@ def add_timeseries(self, df, meta=False): region = df.region[i] variable = df.variable[i] unit = df.unit[i] - jData = java.LinkedHashMap() + jData = JLinkedHashMap() - jData.put(java.Integer(int(df.year[i])), - java.Double(float(df.value[i]))) + jData.put(JInt(int(df.year[i])), + JDouble(float(df.value[i]))) # add the final iteration of the loop self._jobj.addTimeseries(region, variable, time, jData, unit, meta) # if in 'IAMC-style' format else: for i in df.index: - jData = java.LinkedHashMap() + jData = JLinkedHashMap() for j in numcols(df): - jData.put(java.Integer(int(j)), - java.Double(float(df[j][i]))) + jData.put(JInt(int(j)), + JDouble(float(df[j][i]))) time = None self._jobj.addTimeseries(df.region[i], df.variable[i], time, @@ -594,9 +601,9 @@ def remove_timeseries(self, df): df = pd.melt(df, id_vars=['region', 'variable', 'unit'], var_name='year', value_name='value') for name, data in df.groupby(['region', 'variable', 'unit']): - years = java.LinkedList() + years = JLinkedList() for y in data['year']: - years.add(java.Integer(y)) + years.add(JInt(y)) self._jobj.removeTimeseries(name[0], name[1], None, years, name[2]) @@ -835,54 +842,96 @@ def add_set(self, name, key, comment=None): Element(s) to be added. If *name* exists, the elements are appended to existing elements. comment : str or iterable of str, optional - Comment describing the element(s). Only used if *key* is a string - or list/range. + Comment describing the element(s). If given, there must be the + same number of comments as elements. Raises ------ - :class:`jpype.JavaException` + KeyError If the set *name* does not exist. :meth:`init_set` must be called before :meth:`add_set`. + ValueError + For invalid forms or combinations of *key* and *comment*. """ + # TODO expand docstring (here or in doc/source/api.rst) with examples, + # per test_core.test_add_set. self.clear_cache(name=name, ix_type='set') - jSet = self._item('set', name) + # Get index names for set *name*, may raise KeyError + idx_names = self.idx_names(name) - if sys.version_info[0] > 2 and isinstance(key, range): - key = list(key) + # Check arguments and convert to two lists: keys and comments + if len(idx_names) == 0: + # Basic set. Keys must be strings. + if isinstance(key, (dict, pd.DataFrame)): + raise ValueError('dict, DataFrame keys invalid for ' + f'basic set {name!r}') - if (jSet.getDim() == 0) and isinstance(key, list): - for i in range(len(key)): - if comment and i < len(comment): - jSet.addElement(str(key[i]), str(comment[i])) - else: - jSet.addElement(str(key[i])) - elif isinstance(key, pd.DataFrame) or isinstance(key, dict): - if isinstance(key, dict): - key = pd.DataFrame.from_dict(key, orient='columns', dtype=None) - idx_names = self.idx_names(name) - if "comment" in list(key): - for i in key.index: - jSet.addElement(to_jlist(key.loc[i], idx_names), - str(key['comment'][i])) - else: - for i in key.index: - jSet.addElement(to_jlist(key.loc[i], idx_names)) - elif isinstance(key, list): - if isinstance(key[0], list): - for i in range(len(key)): - if comment and i < len(comment): - jSet.addElement(to_jlist( - key[i]), str(comment[i])) - else: - jSet.addElement(to_jlist(key[i])) - else: - if comment: - jSet.addElement(to_jlist(key), str(comment[i])) - else: - jSet.addElement(to_jlist(key)) + # Ensure keys is a list of str + keys = as_str_list(key) else: - jSet.addElement(str(key), str(comment)) + # Set defined over 1+ other sets + + # Check for ambiguous arguments + if comment and isinstance(key, (dict, pd.DataFrame)) and \ + 'comment' in key: + raise ValueError("ambiguous; both key['comment'] and comment " + "given") + + if isinstance(key, pd.DataFrame): + # DataFrame of key values and perhaps comments + try: + # Pop a 'comment' column off the DataFrame, convert to list + comment = key.pop('comment').to_list() + except KeyError: + pass + + # Convert key to list of list of key values + keys = [] + for row in key.to_dict(orient='records'): + keys.append(as_str_list(row, idx_names=idx_names)) + elif isinstance(key, dict): + # Dict of lists of key values + + # Pop a 'comment' list from the dict + comment = key.pop('comment', None) + + # Convert to list of list of key values + keys = list(map(as_str_list, + zip(*[key[i] for i in idx_names]))) + elif isinstance(key[0], str): + # List of key values; wrap + keys = [as_str_list(key)] + elif isinstance(key[0], list): + # List of lists of key values; convert to list of list of str + keys = map(as_str_list, key) + elif isinstance(key, str) and len(idx_names) == 1: + # Bare key given for a 1D set; wrap for convenience + keys = [[key]] + else: + # Other, invalid value + raise ValueError(key) + + # Process comments to a list of str, or let them all be None + comments = as_str_list(comment) if comment else repeat(None, len(keys)) + + # Combine iterators to tuples. If the lengths are mismatched, the + # sentinel value 'False' is filled in + to_add = list(zip_longest(keys, comments, fillvalue=False)) + + # Check processed arguments + for e, c in to_add: + # Check for sentinel values + if e is False: + raise ValueError(f'Comment {c!r} without matching key') + elif c is False: + raise ValueError(f'Key {e!r} without matching comment') + elif len(idx_names) and len(idx_names) != len(e): + raise ValueError(f'{len(e)}-D key {e!r} invalid for ' + f'{len(idx_names)}-D set {name}{idx_names!r}') + + # Send to backend + self._backend('add_set_elements', name, to_add) def remove_set(self, name, key=None): """delete a set from the scenario diff --git a/ixmp/utils.py b/ixmp/utils.py index d5a14f348..c2bbdc932 100644 --- a/ixmp/utils.py +++ b/ixmp/utils.py @@ -26,6 +26,27 @@ def logger(): return _LOGGER +def as_str_list(arg, idx_names=None): + """Convert various *arg* to list of str. + + Several types of arguments are handled: + - None: returned as None. + - str: returned as a length-1 list of str. + - list of values: returned as a list with each value converted to str + - dict, with list of idx_names: the idx_names are used to look up values + in the dict, the resulting list has the corresponding values in the same + order. + + """ + if arg is None: + return None + elif idx_names is None: + # arg must be iterable + return list(map(str, arg)) if islistable(arg) else [str(arg)] + else: + return [str(arg[idx]) for idx in idx_names] + + def isstr(x): """Returns True if x is a string""" return isinstance(x, six.string_types) diff --git a/tests/test_core.py b/tests/test_core.py index 466259295..419a021f8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -128,8 +128,63 @@ def test_add_set(test_mp): scen = ixmp.Scenario(test_mp, *can_args) # Add element to a non-existent set - with pytest.raises(jpype.JException, - match="No Set 'foo' exists in this Scenario!"): + with pytest.raises(KeyError, + match="No Item 'foo' exists in this Scenario!"): + scen.add_set('foo', 'bar') + + scen.remove_solution() + scen.check_out() + + # Add elements to a 0-D set + scen.add_set('i', 'i1') # Name only + scen.add_set('i', 'i2', 'i2 comment') # Name and comment + scen.add_set('i', ['i3']) # List of names, length 1 + scen.add_set('i', ['i4', 'i5']) # List of names, length >1 + scen.add_set('i', range(0, 3)) # Generator (range object) + # Lists of names and comments, length 1 + scen.add_set('i', ['i6'], ['i6 comment']) + # Lists of names and comments, length >1 + scen.add_set('i', ['i7', 'i8'], ['i7 comment', 'i8 comment']) + + # Incorrect usage + + # Lists of different length + with pytest.raises(ValueError, + match="Comment 'extra' without matching key"): + scen.add_set('i', ['i9'], ['i9 comment', 'extra']) + with pytest.raises(ValueError, + match="Key 'extra' without matching comment"): + scen.add_set('i', ['i9', 'extra'], ['i9 comment']) + + # Add elements to a 1D set + scen.init_set('foo', 'i', 'dim_i') + scen.add_set('foo', ['i1']) # Single key + scen.add_set('foo', ['i2'], 'i2 in foo') # Single key and comment + scen.add_set('foo', 'i3') # Bare name automatically wrapped + # Lists of names and comments, length 1 + scen.add_set('foo', ['i6'], ['i6 comment']) + # Lists of names and comments, length >1 + scen.add_set('foo', [['i7'], ['i8']], ['i7 comment', 'i8 comment']) + # Dict + scen.add_set('foo', dict(dim_i=['i7', 'i8'])) + + # Incorrect usage + # Improperly wrapped keys + with pytest.raises(ValueError, match=r"2-D key \['i4', 'i5'\] invalid for " + r"1-D set foo\['dim_i'\]"): + scen.add_set('foo', ['i4', 'i5']) + with pytest.raises(ValueError): + scen.add_set('foo', range(0, 3)) + # Lists of different length + with pytest.raises(ValueError, + match="Comment 'extra' without matching key"): + scen.add_set('i', ['i9'], ['i9 comment', 'extra']) + with pytest.raises(ValueError, + match="Key 'extra' without matching comment"): + scen.add_set('i', ['i9', 'extra'], ['i9 comment']) + # Missing element in the index set + with pytest.raises(ValueError, match="The index set 'i' does not have an " + "element 'bar'!"): scen.add_set('foo', 'bar') From c43acf01c48b66faa9e6578d55902ffe7a86496b Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 17:22:28 +0200 Subject: [PATCH 11/71] Add ts_check_out, ts_commit to Backend; clean loading of JClass --- ixmp/backend/base.py | 21 ++++++++++++++++ ixmp/backend/jdbc.py | 60 +++++++++++++++++++++++--------------------- ixmp/core.py | 44 ++++++++++++++++---------------- 3 files changed, 75 insertions(+), 50 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index a558f5667..b06eeae0f 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -57,6 +57,27 @@ def ts_init(self, ts, annotation=None): """ pass + @abstractmethod + def ts_check_out(self, ts, timeseries_only): + """Check out the ixmp.TimeSeries *s* for modifications. + + Parameters + ---------- + timeseries_only : bool + ??? + """ + pass + + @abstractmethod + def ts_commit(self, ts, comment): + """Commit changes to the ixmp.TimeSeries *s* since the last check_out. + + The method MAY: + + - Modify the version attr of *ts*. + """ + pass + @abstractmethod def s_init(self, s, annotation=None): """Initialize the ixmp.Scenario *s*. diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 142218bc7..f8bd6f1be 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -1,16 +1,10 @@ import os from pathlib import Path import re +from types import SimpleNamespace import jpype -from jpype import ( - JClass, - JPackage, -) -from jpype.types import ( # noqa: F401 - JInt, - JDouble, -) +from jpype import JClass import numpy as np import pandas as pd @@ -29,13 +23,18 @@ 'NOTSET': 'OFF', } -# Java packages, loaded by start_jvm() -jixmp = None # corresponds to at.ac.iiasa.ixmp - # Java classes, loaded by start_jvm() -JLinkedList = None -JHashMap = None -JLinkedHashMap = None +java = SimpleNamespace() + +JAVA_CLASSES = [ + 'java.lang.Double', + 'java.util.HashMap', + 'java.lang.Integer', + 'at.ac.iiasa.ixmp.exceptions.IxException', + 'java.util.LinkedHashMap', + 'java.util.LinkedList', + 'at.ac.iiasa.ixmp.Platform', + ] class JDBCBackend(Backend): @@ -69,13 +68,13 @@ def __init__(self, dbprops=None, dbtype=None, jvmargs=None): "to launch platform") logger().info("launching ixmp.Platform using config file at " "'{}'".format(dbprops)) - self.jobj = jixmp.Platform('Python', str(dbprops)) + self.jobj = java.Platform('Python', str(dbprops)) # if dbtype is specified, launch Platform with local database elif dbtype == 'HSQLDB': dbprops = dbprops or _config.get('DEFAULT_LOCAL_DB_PATH') logger().info("launching ixmp.Platform with local {} database " "at '{}'".format(dbtype, dbprops)) - self.jobj = jixmp.Platform('Python', str(dbprops), dbtype) + self.jobj = java.Platform('Python', str(dbprops), dbtype) else: raise ValueError('Unknown dbtype: {}'.format(dbtype)) except TypeError: @@ -127,6 +126,14 @@ def ts_init(self, ts, annotation=None): # Add to index self.jindex[ts] = jobj + def ts_check_out(self, ts, timeseries_only): + self.jindex[ts].checkOut(timeseries_only) + + def ts_commit(self, ts, comment): + self.jindex[ts].commit(comment) + if ts.version == 0: + ts.version = self.jindex[ts].getVersion() + def ts_discard_changes(self, ts): """Discard all changes and reload from the database.""" self.jindex[ts].discardChanges() @@ -207,7 +214,7 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, # get list of elements, with filter HashMap if provided if filters is not None: - jFilter = JHashMap() + jFilter = java.HashMap() for idx_name in filters.keys(): jFilter.put(idx_name, to_jlist(filters[idx_name])) jList = item.getElements(jFilter) @@ -275,7 +282,7 @@ def s_add_set_elements(self, s, name, elements): # Call with 1 or 2 args jSet.addElement(e, comment) if comment else jSet.addElement(e) - except jixmp.exceptions.IxException as e: + except java.IxException as e: msg = e.message() if 'does not have an element' in msg: # Re-raise as Python ValueError @@ -300,7 +307,7 @@ def _get_item(self, s, ix_type, name, load=True): args = [name] + ([load] if ix_type != 'item' else []) try: return getattr(self.jindex[s], f'get{ix_type.title()}')(*args) - except jixmp.exceptions.IxException as e: + except java.IxException as e: if re.match('No item [^ ]* exists in this Scenario', e.args[0]): # Re-raise as a Python KeyError raise KeyError(f'No {ix_type.title()} {name!r} exists in this ' @@ -346,13 +353,10 @@ def start_jvm(jvmargs=None): jpype.startJVM(*args, **kwargs) - global JLinkedList, JHashMap, JLinkedHashMap, jixmp - # define auxiliary references to Java classes - jixmp = JPackage('at.ac.iiasa.ixmp') - JLinkedList = JClass('java.util.LinkedList') - JHashMap = JClass('java.util.HashMap') - JLinkedHashMap = JClass('java.util.LinkedHashMap') + global java + for class_name in JAVA_CLASSES: + setattr(java, class_name.split('.')[-1], JClass(class_name)) # Conversion methods @@ -369,7 +373,7 @@ def to_pylist(jlist): def to_jdouble(val): """Returns a Java.Double""" - return JDouble(float(val)) + return java.Double(float(val)) def to_jlist(pylist, idx_names=None): @@ -377,7 +381,7 @@ def to_jlist(pylist, idx_names=None): if pylist is None: return None - jList = JLinkedList() + jList = java.LinkedList() if idx_names is None: if islistable(pylist): for key in pylist: @@ -393,7 +397,7 @@ def to_jlist(pylist, idx_names=None): def to_jlist2(arg): """Simple conversion of :class:`list` *arg* to JLinkedList.""" - jlist = JLinkedList() + jlist = java.LinkedList() jlist.addAll(arg) return jlist diff --git a/ixmp/core.py b/ixmp/core.py index 6e5f3fc39..86876709c 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -16,10 +16,7 @@ # TODO remove these direct imports of Java-related methods from .backend.jdbc import ( - JLinkedList, - JLinkedHashMap, - JInt, - JDouble, + java, to_jdouble as _jdouble, to_jlist, to_pylist, @@ -298,7 +295,7 @@ def check_access(self, user, models, access='view'): if isinstance(models, str): return self._jobj.checkModelAccess(user, access, models) else: - models_list = JLinkedList() + models_list = java.LinkedList() for model in models: models_list.add(model) access_map = self._jobj.checkModelAccess(user, access, models_list) @@ -410,18 +407,21 @@ def check_out(self, timeseries_only=False): 'use `Scenario.remove_solution()` or ' '`Scenario.clone(..., keep_solution=False)`' ) - self._jobj.checkOut(timeseries_only) + self._backend('check_out', timeseries_only) def commit(self, comment): """Commit all changed data to the database. - :attr:`version` is not incremented. + If the TimeSeries was newly created (with ``version='new'``), + :attr:`version` is updated with a new version number assigned by the + backend. Otherwise, :meth:`commit` does not change the :attr:`version`. + + Parameters + ---------- + comment : str + Description of the changes being committed. """ - self._jobj.commit(comment) - # if version == 0, this is a new instance - # and a new version number was assigned after the initial commit - if self.version == 0: - self.version = self._jobj.getVersion() + self._backend('commit', comment) def discard_changes(self): """Discard all changes and reload from the database.""" @@ -485,7 +485,7 @@ def add_timeseries(self, df, meta=False): variable = df.variable[0] unit = df.unit[0] time = None - jData = JLinkedHashMap() + jData = java.LinkedHashMap() for i in df.index: if not (region == df.region[i] and variable == df.variable[i] @@ -498,21 +498,21 @@ def add_timeseries(self, df, meta=False): region = df.region[i] variable = df.variable[i] unit = df.unit[i] - jData = JLinkedHashMap() + jData = java.LinkedHashMap() - jData.put(JInt(int(df.year[i])), - JDouble(float(df.value[i]))) + jData.put(java.Integer(int(df.year[i])), + java.Double(float(df.value[i]))) # add the final iteration of the loop self._jobj.addTimeseries(region, variable, time, jData, unit, meta) # if in 'IAMC-style' format else: for i in df.index: - jData = JLinkedHashMap() + jData = java.LinkedHashMap() for j in numcols(df): - jData.put(JInt(int(j)), - JDouble(float(df[j][i]))) + jData.put(java.Integer(int(j)), + java.Double(float(df[j][i]))) time = None self._jobj.addTimeseries(df.region[i], df.variable[i], time, @@ -601,9 +601,9 @@ def remove_timeseries(self, df): df = pd.melt(df, id_vars=['region', 'variable', 'unit'], var_name='year', value_name='value') for name, data in df.groupby(['region', 'variable', 'unit']): - years = JLinkedList() + years = java.LinkedList() for y in data['year']: - years.add(JInt(y)) + years.add(java.Integer(y)) self._jobj.removeTimeseries(name[0], name[1], None, years, name[2]) @@ -1108,7 +1108,7 @@ def change_scalar(self, name, val, unit, comment=None): Description of the change. """ self.clear_cache(name=name, ix_type='par') - self._item('par', name).addElement(_jdouble(val), unit, comment) + self._item('par', name).addElement(java.Double(val), unit, comment) def remove_par(self, name, key=None): """Remove parameter values or an entire parameter. From db2e47663aa33e2316643bfc32e32afae050ef19 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 17:44:43 +0200 Subject: [PATCH 12/71] Add s_add_par_elements to Backend, JDBCBackend --- ixmp/backend/base.py | 24 +++++++++++++++++ ixmp/backend/jdbc.py | 18 +++++++++++++ ixmp/core.py | 62 +++++++++++++++++++++++--------------------- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index b06eeae0f..92c032f26 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -153,3 +153,27 @@ def s_add_set_elements(self, s, name, elements): If the Backend encounters any error adding the key. """ pass + + @abstractmethod + def s_add_par_values(self, s, name, elements): + """Add values to parameter *name* in Scenario *s*. + + Parameters + ---------- + elements : list of 4-tuples + The tuple members are, respectively: + + 1. Key: str or list of str or None. + 2. Value: float. + 3. Unit: str or None. + 4. Comment: str or None. + + Raises + ------ + ValueError + If *elements* contain invalid values, e.g. key values not in the + index set(s). + Exception + If the Backend encounters any error adding the parameter values. + """ + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index f8bd6f1be..38be6f7c0 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -290,6 +290,24 @@ def s_add_set_elements(self, s, name, elements): else: raise RuntimeError('Unhandled Java exception') from e + def s_add_par_values(self, s, name, elements): + """Add values to parameter *name* in Scenario *s*.""" + jPar = self._get_item(s, 'par', name) + + for key, value, unit, comment in elements: + args = [] + if key: + args.append(to_jlist2(key)) + args.extend([java.Double(value), unit]) + if comment: + args.append(comment) + + # Activates one of 3 signatures for addElement: + # - (key, value, unit, comment) + # - (key, value, unit) + # - (value, unit, comment) + jPar.addElement(*args) + # Helpers; not part of the Backend interface def _get_item(self, s, ix_type, name, load=True): diff --git a/ixmp/core.py b/ixmp/core.py index 86876709c..a0b1b7fa8 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -3,7 +3,6 @@ from itertools import repeat, zip_longest import logging import os -import sys from subprocess import check_call import warnings from warnings import warn @@ -1003,23 +1002,23 @@ def add_par(self, name, key, val=None, unit=None, comment=None): """ self.clear_cache(name=name, ix_type='par') - jPar = self._item('par', name) - - if sys.version_info[0] > 2 and isinstance(key, range): + if isinstance(key, range): key = list(key) + elements = [] + if isinstance(key, pd.DataFrame) and "key" in list(key): if "comment" in list(key): for i in key.index: - jPar.addElement(str(key['key'][i]), - _jdouble(key['value'][i]), - str(key['unit'][i]), - str(key['comment'][i])) + elements.append((str(key['key'][i]), + float(key['value'][i]), + str(key['unit'][i]), + str(key['comment'][i]))) else: for i in key.index: - jPar.addElement(str(key['key'][i]), - _jdouble(key['value'][i]), - str(key['unit'][i])) + elements.append((str(key['key'][i]), + float(key['value'][i]), + str(key['unit'][i]))) elif isinstance(key, pd.DataFrame) or isinstance(key, dict): if isinstance(key, dict): @@ -1027,38 +1026,40 @@ def add_par(self, name, key, val=None, unit=None, comment=None): idx_names = self.idx_names(name) if "comment" in list(key): for i in key.index: - jPar.addElement(to_jlist(key.loc[i], idx_names), - _jdouble(key['value'][i]), - str(key['unit'][i]), - str(key['comment'][i])) + elements.append((to_jlist(key.loc[i], idx_names), + float(key['value'][i]), + str(key['unit'][i]), + str(key['comment'][i]))) else: for i in key.index: - jPar.addElement(to_jlist(key.loc[i], idx_names), - _jdouble(key['value'][i]), - str(key['unit'][i])) + elements.append((to_jlist(key.loc[i], idx_names), + float(key['value'][i]), + str(key['unit'][i]), + None)) elif isinstance(key, list) and isinstance(key[0], list): unit = unit or ["???"] * len(key) for i in range(len(key)): if comment and i < len(comment): - jPar.addElement(to_jlist(key[i]), _jdouble(val[i]), - str(unit[i]), str(comment[i])) + elements.append((to_jlist(key[i]), float(val[i]), + str(unit[i]), str(comment[i]))) else: - jPar.addElement(to_jlist(key[i]), _jdouble(val[i]), - str(unit[i])) + elements.append((to_jlist(key[i]), float(val[i]), + str(unit[i]), None)) elif isinstance(key, list) and isinstance(val, list): unit = unit or ["???"] * len(key) for i in range(len(key)): if comment and i < len(comment): - jPar.addElement(str(key[i]), _jdouble(val[i]), - str(unit[i]), str(comment[i])) + elements.append((str(key[i]), float(val[i]), + str(unit[i]), str(comment[i]))) else: - jPar.addElement(str(key[i]), _jdouble(val[i]), - str(unit[i])) + elements.append((str(key[i]), float(val[i]), + str(unit[i]), None)) elif isinstance(key, list) and not isinstance(val, list): - jPar.addElement(to_jlist( - key), _jdouble(val), unit, comment) + elements.append((to_jlist(key), float(val), unit, comment)) else: - jPar.addElement(str(key), _jdouble(val), unit, comment) + elements.append((str(key), float(val), unit, comment)) + + self._backend('add_par_values', name, elements) def init_scalar(self, name, val, unit, comment=None): """Initialize a new scalar. @@ -1108,7 +1109,8 @@ def change_scalar(self, name, val, unit, comment=None): Description of the change. """ self.clear_cache(name=name, ix_type='par') - self._item('par', name).addElement(java.Double(val), unit, comment) + self._backend('add_par_values', name, + [(None, float(val), unit, comment)]) def remove_par(self, name, key=None): """Remove parameter values or an entire parameter. From 986bec692bf8f461960047ba377af88e063354c3 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 18:37:38 +0200 Subject: [PATCH 13/71] Add Backend documentation --- doc/source/api-backend.rst | 68 +++++++++++++++++++++++++++++++++++++ doc/source/api.rst | 1 + ixmp/backend/__init__.py | 1 + ixmp/backend/base.py | 69 +++++++++++++++++--------------------- ixmp/backend/jdbc.py | 7 ++-- 5 files changed, 105 insertions(+), 41 deletions(-) create mode 100644 doc/source/api-backend.rst diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst new file mode 100644 index 000000000..438bf2be6 --- /dev/null +++ b/doc/source/api-backend.rst @@ -0,0 +1,68 @@ +.. currentmodule:: ixmp.backend + +Storage back ends (:mod:`ixmp.backend` package) +=============================================== + +By default, the |ixmp| is installed with :class:`ixmp.backend.jdbc.JDBCBackend`, which can store data many types of relational database management systems (RDBMS) that have Java DataBase Connector (JDBC) interfaces—hence its name. +These include: + +- ``dbtype='HSQLDB'``: databases in local files. +- Remote databases. This is accomplished by creating a :class:`ixmp.Platform` with the ``dbprops`` argument pointing a file that specifies JDBC information. For instance:: + + jdbc.driver = oracle.jdbc.driver.OracleDriver + jdbc.url = jdbc:oracle:thin:@database-server.example.com:1234:SCHEMA + jdbc.user = USER + jdbc.pwd = PASSWORD + +However, |ixmp| is extensible to support other methods of storing data: in non-JDBC RDBMS, non-relational databases, local files, memory, or other ways. +Developers wishing to add such capabilities may subclass :class:`ixmp.backend.base.Backend` and implement its methods. + +Implementing custom backends +---------------------------- + +In the following, the words MUST, MAY, etc. have specific meanings as described in RFC ____. + +- :class:`ixmp.Platform` implements a *user-friendly* API for scientific programming. + This means its methods can take many types of arguments, check, and transform them in a way that provides modeler-users with an easy, intuitive workflow. +- In contrast, :class:`Backend` has a very simple API that accepts and returns + arguments in basic Python data types and structures. + Custom backends need not to perform argument checking: merely store and retrieve data reliably. +- Some methods below are decorated as :meth:`abc.abstractmethod`; this means + they MUST be overridden by a subclass of Backend. +- Others that are not so decorated and have “(optional)” in their signature are not required. The behaviour in base.Backend—often, nothing—is an acceptable default behaviour. + Subclasses MAY extend or replace this behaviour as desired, so long as the methods still perform the actions described in the description. + +.. automodule:: ixmp.backend + :members: BACKENDS + +.. autoclass:: ixmp.backend.jdbc.JDBCBackend + +.. autoclass:: ixmp.backend.base.Backend + :members: + + Methods related to :class:`ixmp.Platform`: + + .. autosummary:: + set_log_level + open_db + close_db + units + + Methods related to :class:`ixmp.TimeSeries`: + + .. autosummary:: + ts_init + ts_check_out + ts_commit + + Methods related to :class:`ixmp.Scenario`: + + .. autosummary:: + s_init + s_has_solution + s_list_items + s_init_item + s_item_index + s_item_elements + s_add_set_elements + s_add_par_values diff --git a/doc/source/api.rst b/doc/source/api.rst index 0a6cd3e6a..7e1086e3f 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -9,6 +9,7 @@ On separate pages: :maxdepth: 2 api-python + api-backend reporting On this page: diff --git a/ixmp/backend/__init__.py b/ixmp/backend/__init__.py index 9d26466d5..f62bd3290 100644 --- a/ixmp/backend/__init__.py +++ b/ixmp/backend/__init__.py @@ -1,6 +1,7 @@ from .jdbc import JDBCBackend +#: Mapping from names to available backends BACKENDS = { 'jdbc': JDBCBackend, } diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 92c032f26..378f44d4e 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -2,14 +2,7 @@ class Backend(ABC): - """Abstract base class for backends. - - Some methods below are decorated as @abstractmethod; this means they MUST - be overridden by a subclass of Backend. Others that are not decorated - mean that the behaviour here is the default behaviour; subclasses MAY - leave, replace or extend this behaviour as needed. - - """ + """Abstract base class for backends.""" def __init__(self): """Initialize the backend.""" @@ -17,39 +10,37 @@ def __init__(self): @abstractmethod def set_log_level(self, level): - """Set logging level for the backend.""" + """Set logging level for the backend and other code (required).""" pass def open_db(self): - """(Re-)open the database connection. + """(Re-)open a database connection (optional). - The database connection is opened automatically for many operations. - After calling :meth:`close_db`, it must be re-opened. + A backend MAY connect to a database server. This method opens the + database connection if it is closed. """ pass def close_db(self): - """Close the database connection. + """Close a database connection (optional). - Some backend database connections can only be used by one - :class:`Backend` instance at a time. Any existing connection must be - closed before a new one can be opened. + Close a database connection if it is open. """ pass @abstractmethod def units(self): - """Return all units described in the database. + """Return all registered units of measurement (required). Returns ------- - list + list of str """ pass @abstractmethod def ts_init(self, ts, annotation=None): - """Initialize the ixmp.TimeSeries *ts*. + """Initialize the TimeSeries *ts* (required). The method MAY: @@ -59,7 +50,7 @@ def ts_init(self, ts, annotation=None): @abstractmethod def ts_check_out(self, ts, timeseries_only): - """Check out the ixmp.TimeSeries *s* for modifications. + """Check out the TimeSeries *s* for modifications (required). Parameters ---------- @@ -70,7 +61,7 @@ def ts_check_out(self, ts, timeseries_only): @abstractmethod def ts_commit(self, ts, comment): - """Commit changes to the ixmp.TimeSeries *s* since the last check_out. + """Commit changes to the TimeSeries *s* (required). The method MAY: @@ -80,7 +71,7 @@ def ts_commit(self, ts, comment): @abstractmethod def s_init(self, s, annotation=None): - """Initialize the ixmp.Scenario *s*. + """Initialize the Scenario *s* (required). The method MAY: @@ -89,26 +80,26 @@ def s_init(self, s, annotation=None): pass @abstractmethod - def s_has_solution(self): - """Return :obj:`True` if the Scenario has been solved. + def s_has_solution(self, s): + """Return :obj:`True` if Scenario *s* has been solved (required). - If :obj:`True`, model solution data exists in the database. + If :obj:`True`, model solution data is available from the Backend. """ pass @abstractmethod def s_list_items(self, s, type): - """Return a list of items of *type* in the Scenario *s*.""" + """Return a list of items of *type* in Scenario *s* (required).""" pass @abstractmethod def s_init_item(self, s, type, name): - """Initialize or create a new item *name* of *type* in Scenario *s*.""" + """Initialize an item *name* of *type* in Scenario *s* (required).""" pass @abstractmethod def s_item_index(self, s, name, sets_or_names): - """Return the index sets or names of item *name*. + """Return the index sets or names of item *name* (required). Parameters ---------- @@ -119,7 +110,7 @@ def s_item_index(self, s, name, sets_or_names): @abstractmethod def s_item_elements(self, s, type, name, filters=None, has_value=False, has_level=False): - """Return elements of item *name* in Scenario *s*. + """Return elements of item *name* in Scenario *s* (required). The return type varies according to the *type* and contents: @@ -134,15 +125,16 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, @abstractmethod def s_add_set_elements(self, s, name, elements): - """Add elements to set *name* in Scenario *s*. + """Add elements to set *name* in Scenario *s* (required). Parameters ---------- - elements : list of 2-tuples - The first element of each tuple is a key (str or list of str). - The number and order of key dimensions must match the index of - *name*, if any. The second element is a str comment describing the - key, or None. + elements : iterable of 2-tuples + The tuple members are, respectively: + + 1. Key: str or list of str. The number and order of key dimensions + must match the index of *name*, if any. + 2. Comment: str or None. An optional description of the key. Raises ------ @@ -156,14 +148,15 @@ def s_add_set_elements(self, s, name, elements): @abstractmethod def s_add_par_values(self, s, name, elements): - """Add values to parameter *name* in Scenario *s*. + """Add values to parameter *name* in Scenario *s* (required). Parameters ---------- - elements : list of 4-tuples + elements : iterable of 4-tuples The tuple members are, respectively: - 1. Key: str or list of str or None. + 1. Key: str or list of str or (for a scalar, or 0-dimensional + parameter) None. 2. Value: float. 3. Unit: str or None. 4. Comment: str or None. diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 38be6f7c0..c2dbb3d30 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -38,12 +38,13 @@ class JDBCBackend(Backend): - """Backend using JDBC to connect to Oracle and HSQLDB instances. + """Backend using JPype and JDBC to connect to Oracle and HSQLDB instances. - Much of the code of this backend is implemented in Java, in the - ixmp_source repository. + Much of the code of this backend is implemented in Java code in the + iiasa/ixmp_source Github repository. Among other things, this backend: + - Catches Java exceptions such as ixmp.exceptions.IxException, and re-raises them as appropriate Python exceptions. From 25fe9bab91017573758ab069bafb6c05b62e8628 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 18:53:42 +0200 Subject: [PATCH 14/71] Add get_units to Backend --- ixmp/backend/base.py | 18 +++++++++++++++++- ixmp/backend/jdbc.py | 8 +++++++- ixmp/core.py | 15 ++++++--------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 378f44d4e..3071deff8 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -29,7 +29,23 @@ def close_db(self): pass @abstractmethod - def units(self): + def get_nodes(self): + """Iterate over all nodes (required). + + Yields + ------- + tuple + The four members of each tuple are: + + 1. Name or synonym: str + 2. Name: str or None. + 3. Parent: str. + 4. Hierarchy: str. + """ + pass + + @abstractmethod + def get_units(self): """Return all registered units of measurement (required). Returns diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index c2dbb3d30..ac899c216 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -103,7 +103,13 @@ def close_db(self): """ self.jobj.closeDB() - def units(self): + def get_nodes(self): + for r in self.jobj.listNodes('%'): + n, p, h = r.getName(), r.getParent(), r.getHierarchy() + yield (n, None, p, h) + yield from [(s, n, p, h) for s in (r.getSynonyms() or [])] + + def get_units(self): """Return all units described in the database.""" return to_pylist(self.jobj.getUnitList()) diff --git a/ixmp/core.py b/ixmp/core.py index a0b1b7fa8..e2d6e009e 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -88,7 +88,6 @@ class Platform: _backend_direct = [ 'open_db', 'close_db', - 'units', ] def __init__(self, *args, backend='jdbc', **backend_args): @@ -219,6 +218,9 @@ def add_unit(self, unit, comment='None'): msg = 'unit `{}` is already defined in the platform instance' logger().info(msg.format(unit)) + def units(self): + return self._backend.get_units() + def regions(self): """Return all regions defined for the IAMC-style timeseries format including known synonyms. @@ -227,14 +229,9 @@ def regions(self): ------- :class:`pandas.DataFrame` """ - lst = [] - for r in self._jobj.listNodes('%'): - n, p, h = (r.getName(), r.getParent(), r.getHierarchy()) - lst.extend([(n, None, p, h)]) - lst.extend([(s, n, p, h) for s in (r.getSynonyms() or [])]) - region = pd.DataFrame(lst) - region.columns = ['region', 'mapped_to', 'parent', 'hierarchy'] - return region + NODE_FIELDS = ['region', 'mapped_to', 'parent', 'hierarchy'] + return pd.DataFrame(self._backend.get_nodes(), + columns=NODE_FIELDS) def add_region(self, region, hierarchy, parent='World'): """Define a region including a hierarchy level and a 'parent' region. From 789fd85d9e254e4f1c06ead300d2c6c18c008784 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 19:07:32 +0200 Subject: [PATCH 15/71] Add set_unit to Backend --- ixmp/backend/base.py | 4 ++++ ixmp/backend/jdbc.py | 8 +++----- ixmp/core.py | 11 +++++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 3071deff8..a3580ba1b 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -44,6 +44,10 @@ def get_nodes(self): """ pass + @abstractmethod + def set_unit(self, name, comment): + pass + @abstractmethod def get_units(self): """Return all registered units of measurement (required). diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index ac899c216..c2e67f62b 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -109,6 +109,9 @@ def get_nodes(self): yield (n, None, p, h) yield from [(s, n, p, h) for s in (r.getSynonyms() or [])] + def set_unit(self, name, comment): + self.jobj.addUnitToDB(name, comment) + def get_units(self): """Return all units described in the database.""" return to_pylist(self.jobj.getUnitList()) @@ -396,11 +399,6 @@ def to_pylist(jlist): return np.array(jlist.toArray()[:]) -def to_jdouble(val): - """Returns a Java.Double""" - return java.Double(float(val)) - - def to_jlist(pylist, idx_names=None): """Convert *pylist* to a jLinkedList.""" if pylist is None: diff --git a/ixmp/core.py b/ixmp/core.py index e2d6e009e..129ef367c 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -16,7 +16,6 @@ # TODO remove these direct imports of Java-related methods from .backend.jdbc import ( java, - to_jdouble as _jdouble, to_jlist, to_pylist, filtered, @@ -212,11 +211,12 @@ def add_unit(self, unit, comment='None'): Annotation describing the unit or why it was added. The current database user and timestamp are appended automatically. """ - if unit not in self.units(): - self._jobj.addUnitToDB(unit, comment) - else: + if unit in self.units(): msg = 'unit `{}` is already defined in the platform instance' logger().info(msg.format(unit)) + return + + self._backend.set_unit(unit, comment) def units(self): return self._backend.get_units() @@ -1073,8 +1073,7 @@ def init_scalar(self, name, val, unit, comment=None): Description of the scalar. """ self.init_par(name, None, None) - jPar = self._item('par', name) - jPar.addElement(_jdouble(val), unit, comment) + self.change_scalar(name, val, unit, comment) def scalar(self, name): """Return the value and unit of a scalar. From 5923d1aeadb53255259b8d09b0a6eb3e5000eb3b Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 19:08:33 +0200 Subject: [PATCH 16/71] Add set_node to Backend --- ixmp/backend/base.py | 4 ++++ ixmp/backend/jdbc.py | 6 ++++++ ixmp/core.py | 26 ++++++++++++++------------ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index a3580ba1b..fa680feee 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -28,6 +28,10 @@ def close_db(self): """ pass + @abstractmethod + def set_node(self, name, parent=None, hierarchy=None, synonym=None): + pass + @abstractmethod def get_nodes(self): """Iterate over all nodes (required). diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index c2e67f62b..60c2ebc2d 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -103,6 +103,12 @@ def close_db(self): """ self.jobj.closeDB() + def set_node(self, name, parent=None, hierarchy=None, synonym=None): + if parent and hierarchy and not synonym: + self.jobj.addNode(name, parent, hierarchy) + elif synonym and not (parent or hierarchy): + self.jobj.addNodeSynonym(synonym, name) + def get_nodes(self): for r in self.jobj.listNodes('%'): n, p, h = r.getName(), r.getParent(), r.getHierarchy() diff --git a/ixmp/core.py b/ixmp/core.py index 129ef367c..52023b478 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -245,16 +245,17 @@ def add_region(self, region, hierarchy, parent='World'): ---------- region : str Name of the region. - hierarchy : str - Hierarchy level of the region (e.g., country, R11, basin) parent : str, default 'World' Assign a 'parent' region. + hierarchy : str + Hierarchy level of the region (e.g., country, R11, basin) """ - _regions = self.regions() - if region not in list(_regions['region']): - self._jobj.addNode(region, parent, hierarchy) - else: - _logger_region_exists(_regions, region) + for r in self._backend.get_nodes(): + if r[1] == region: + _logger_region_exists(self.regions(), region) + return + + self._backend.set_node(region, parent, hierarchy) def add_region_synomym(self, region, mapped_to): """Define a synomym for a `region`. @@ -269,11 +270,12 @@ def add_region_synomym(self, region, mapped_to): mapped_to : str Name of the region to which the synonym should be mapped. """ - _regions = self.regions() - if region not in list(_regions['region']): - self._jobj.addNodeSynonym(mapped_to, region) - else: - _logger_region_exists(_regions, region) + for r in self._backend.get_nodes(): + if r[1] == region: + _logger_region_exists(self.regions(), region) + return + + self._backend.set_node(region, synonym=mapped_to) def check_access(self, user, models, access='view'): """Check access to specific model From e3635d5d790058374684f56c26265c356a4545e5 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 19:12:58 +0200 Subject: [PATCH 17/71] Avoid loading item to get indices in JDBCBackend --- ixmp/backend/jdbc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 60c2ebc2d..7146009c4 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -220,7 +220,7 @@ def s_init_item(self, s, type, name, idx_sets, idx_names): func(name, idx_sets, idx_names) def s_item_index(self, s, name, sets_or_names): - jitem = self._get_item(s, 'item', name) + jitem = self._get_item(s, 'item', name, load=False) return list(getattr(jitem, f'getIdx{sets_or_names.title()}')()) def s_item_elements(self, s, type, name, filters=None, has_value=False, From aeb694fac1d35e0133f592aaa7cdc9b6a6d69341 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 19:13:34 +0200 Subject: [PATCH 18/71] Use Scenario.idx_names in reporting.utils.keys_for_quantity --- ixmp/reporting/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ixmp/reporting/utils.py b/ixmp/reporting/utils.py index 07a842206..c16766e5a 100644 --- a/ixmp/reporting/utils.py +++ b/ixmp/reporting/utils.py @@ -164,8 +164,7 @@ def keys_for_quantity(ix_type, name, scenario): # loading the associated data # NB this is used instead of .getIdxSets, since the same set may index more # than one dimension of the same variable. - dims = _find_dims(scenario._item(ix_type, name, load=False) - .getIdxNames().toArray()) + dims = scenario.idx_names(name) # Column for retrieving data column = 'value' if ix_type == 'par' else 'lvl' From 5edf5b33f62ca5b9d61d64719d943fa2ec965770 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 15:35:46 -0400 Subject: [PATCH 19/71] Add get_auth to Backend --- ixmp/backend/base.py | 7 +++++++ ixmp/backend/jdbc.py | 3 +++ ixmp/core.py | 20 +++++++++----------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index fa680feee..938f9e253 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -28,6 +28,13 @@ def close_db(self): """ pass + def get_auth(self, user, models, kind): + """Return user authorization for models (optional). + + If the Backend implements access control… + """ + return {model: True for model in models} + @abstractmethod def set_node(self, name, parent=None, hierarchy=None, synonym=None): pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 7146009c4..7cb3c4ad8 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -103,6 +103,9 @@ def close_db(self): """ self.jobj.closeDB() + def get_auth(self, user, models, kind): + return self.jobj.checkModelAccess(user, kind, to_jlist2(models)) + def set_node(self, name, parent=None, hierarchy=None, synonym=None): if parent and hierarchy and not synonym: self.jobj.addNode(name, parent, hierarchy) diff --git a/ixmp/core.py b/ixmp/core.py index 52023b478..63897994c 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -278,7 +278,7 @@ def add_region_synomym(self, region, mapped_to): self._backend.set_node(region, synonym=mapped_to) def check_access(self, user, models, access='view'): - """Check access to specific model + """Check access to specific models. Parameters ---------- @@ -288,19 +288,17 @@ def check_access(self, user, models, access='view'): Model(s) name access : str, optional Access type - view or edit - """ + Returns + ------- + bool or dict of bool + """ + models_list = as_str_list(models) + result = self._backend.get_auth(user, models_list, access) if isinstance(models, str): - return self._jobj.checkModelAccess(user, access, models) + return result[models] else: - models_list = java.LinkedList() - for model in models: - models_list.add(model) - access_map = self._jobj.checkModelAccess(user, access, models_list) - result = {} - for model in models: - result[model] = access_map.get(model) == 1 - return result + return {model: result.get(model) == 1 for model in models_list} def _logger_region_exists(_regions, r): From 554f764c23b0b1078613ce9505c8cff4973a5780 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 17:05:51 -0400 Subject: [PATCH 20/71] Add clone, get_scenarios to Backend --- ixmp/backend/__init__.py | 1 + ixmp/backend/base.py | 10 +++++++++ ixmp/backend/jdbc.py | 34 +++++++++++++++++++++++++------ ixmp/core.py | 44 ++++++++++++---------------------------- 4 files changed, 52 insertions(+), 37 deletions(-) diff --git a/ixmp/backend/__init__.py b/ixmp/backend/__init__.py index f62bd3290..00e065b5f 100644 --- a/ixmp/backend/__init__.py +++ b/ixmp/backend/__init__.py @@ -1,3 +1,4 @@ +from .base import FIELDS # noqa: F401 from .jdbc import JDBCBackend diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 938f9e253..c7d3d5518 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -1,6 +1,16 @@ from abc import ABC, abstractmethod +# List of field names for lists or tuples returned by Backend API methods +FIELDS = { + 'get_nodes': ('region', 'mapped_to', 'parent', 'hierarchy'), + 'get_scenarios': ('model', 'scenario', 'scheme', 'is_default', + 'is_locked', 'cre_user', 'cre_date', 'upd_user', + 'upd_date', 'lock_user', 'lock_date', 'annotation', + 'version'), +} + + class Backend(ABC): """Abstract base class for backends.""" diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 7cb3c4ad8..b323e6c6c 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -10,7 +10,7 @@ from ixmp.config import _config from ixmp.utils import islistable, logger -from ixmp.backend.base import Backend +from ixmp.backend.base import Backend, FIELDS # Map of Python to Java log levels @@ -27,13 +27,14 @@ java = SimpleNamespace() JAVA_CLASSES = [ + 'at.ac.iiasa.ixmp.exceptions.IxException', + 'at.ac.iiasa.ixmp.objects.Scenario', + 'at.ac.iiasa.ixmp.Platform', 'java.lang.Double', - 'java.util.HashMap', 'java.lang.Integer', - 'at.ac.iiasa.ixmp.exceptions.IxException', + 'java.util.HashMap', 'java.util.LinkedHashMap', 'java.util.LinkedList', - 'at.ac.iiasa.ixmp.Platform', ] @@ -125,6 +126,16 @@ def get_units(self): """Return all units described in the database.""" return to_pylist(self.jobj.getUnitList()) + def get_scenarios(self, default, model, scenario): + # List> + scenarios = self.jobj.getScenarioList(default, model, scenario) + + for s in scenarios: + data = [] + for field in FIELDS['get_scenarios']: + data.append(int(s[field]) if field == 'version' else s[field]) + yield data + # Timeseries methods def ts_init(self, ts, annotation=None): @@ -188,8 +199,7 @@ def s_init(self, s, scheme=None, annotation=None): elif isinstance(s.version, int): jobj = self.jobj.getScenario(s.model, s.scenario, s.version) # constructor for `message_ix.Scenario.__init__` or `clone()` function - elif isinstance(s.version, - JClass('at.ac.iiasa.ixmp.objects.Scenario')): + elif isinstance(s.version, java.Scenario): jobj = s.version elif s.version is None: jobj = self.jobj.getScenario(s.model, s.scenario) @@ -202,6 +212,18 @@ def s_init(self, s, scheme=None, annotation=None): # Add to index self.jindex[s] = jobj + def s_clone(self, s, target_backend, model, scenario, annotation, + keep_solution, first_model_year=None): + if not isinstance(target_backend, self.__class__): + raise RuntimeError('Clone only possible between two instances of' + f'{self.__class__.__name__}') + + args = [model, scenario, annotation, keep_solution] + if first_model_year: + args.append(first_model_year) + # Reference to the cloned Java object + return self.jindex[s].clone(target_backend.jobj, *args) + def s_has_solution(self, s): return self.jindex[s].hasSolution() diff --git a/ixmp/core.py b/ixmp/core.py index 63897994c..cc665daac 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -11,7 +11,7 @@ import pandas as pd from ixmp import model_settings -from .backend import BACKENDS +from .backend import BACKENDS, FIELDS # TODO remove these direct imports of Java-related methods from .backend.jdbc import ( @@ -102,14 +102,6 @@ def __init__(self, *args, backend='jdbc', **backend_args): backend_cls = BACKENDS[backend] self._backend = backend_cls(**backend_args) - @property - def _jobj(self): - """Shim to allow existing code that references ._jobj to work.""" - # TODO address all such warnings, then remove - loc = inspect.stack()[1].function - warn(f'Accessing Platform._jobj in {loc}') - return self._backend.jobj - def __getattr__(self, name): """Convenience for methods of Backend.""" return getattr(self._backend, name) @@ -164,24 +156,8 @@ def scenario_list(self, default=True, model=None, scen=None): lock time. - ``annotation``: description of the Scenario or changelog. """ - mod_scen_list = self._jobj.getScenarioList(default, model, scen) - - mod_range = range(mod_scen_list.size()) - cols = ['model', 'scenario', 'scheme', 'is_default', 'is_locked', - 'cre_user', 'cre_date', 'upd_user', 'upd_date', - 'lock_user', 'lock_date', 'annotation'] - - data = {} - for i in cols: - data[i] = [str(mod_scen_list.get(j).get(i)) for j in mod_range] - - data['version'] = [int(str(mod_scen_list.get(j).get('version'))) - for j in mod_range] - cols.append("version") - - df = pd.DataFrame.from_dict(data, orient='columns', dtype=None) - df = df[cols] - return df + return pd.DataFrame(self._backend.get_scenarios(default, model, scen), + columns=FIELDS['get_scenarios']) def Scenario(self, model, scen, version=None, scheme=None, annotation=None, cache=False): @@ -229,9 +205,8 @@ def regions(self): ------- :class:`pandas.DataFrame` """ - NODE_FIELDS = ['region', 'mapped_to', 'parent', 'hierarchy'] return pd.DataFrame(self._backend.get_nodes(), - columns=NODE_FIELDS) + columns=FIELDS['get_nodes']) def add_region(self, region, hierarchy, parent='World'): """Define a region including a hierarchy level and a 'parent' region. @@ -1253,13 +1228,20 @@ def clone(self, model=None, scenario=None, annotation=None, platform = platform or self.platform model = model or self.model scenario = scenario or self.scenario - args = [platform._jobj, model, scenario, annotation, keep_solution] + + args = [platform._backend, model, scenario, annotation, keep_solution] if check_year(first_model_year, 'first_model_year'): args.append(first_model_year) scenario_class = self.__class__ + + # NB cloning happens entirely within the Java code. This requires + # 'jclone', a reference to a Java object, to be returned here... + jclone = self._backend('clone', *args) + # ...and passed in to the constructor here. To avoid this, would + # need to adjust the ixmp_source code. return scenario_class(platform, model, scenario, cache=self._cache, - version=self._jobj.clone(*args)) + version=jclone) def to_gdx(self, path, filename, include_var_equ=False): """export the scenario data to GAMS gdx From 35b6f21d8effc971f7426c891568babec829fd0c Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 15 Aug 2019 23:13:12 -0400 Subject: [PATCH 21/71] Implement ts_set / simplify add_timeseries --- doc/source/api-backend.rst | 7 ++- ixmp/backend/base.py | 29 ++++++++++++ ixmp/backend/jdbc.py | 19 ++++++++ ixmp/core.py | 92 ++++++++++++++++++++------------------ ixmp/utils.py | 1 + 5 files changed, 104 insertions(+), 44 deletions(-) diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index 438bf2be6..3c24bfddd 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -46,7 +46,9 @@ In the following, the words MUST, MAY, etc. have specific meanings as described set_log_level open_db close_db - units + get_nodes + get_scenarios + get_units Methods related to :class:`ixmp.TimeSeries`: @@ -54,6 +56,9 @@ In the following, the words MUST, MAY, etc. have specific meanings as described ts_init ts_check_out ts_commit + ts_set + ts_get + ts_delete Methods related to :class:`ixmp.Scenario`: diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index c7d3d5518..a7f60af11 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -65,6 +65,10 @@ def get_nodes(self): """ pass + @abstractmethod + def get_scenarios(self, default, model, scenario): + pass + @abstractmethod def set_unit(self, name, comment): pass @@ -110,6 +114,31 @@ def ts_commit(self, ts, comment): """ pass + @abstractmethod + def ts_get(self, ts): + """Retrieve time-series data.""" + pass + + @abstractmethod + def ts_set(self, ts, region, variable, data, unit, meta): + """Store time-series data. + + Parameters + ---------- + region, variable, time, unit : str + Indices for the data. + data : dict (int -> float) + Mapping from year to value. + meta : bool + Metadata flag. + """ + pass + + @abstractmethod + def ts_delete(self, ts): + """Remove time-series data.""" + pass + @abstractmethod def s_init(self, s, annotation=None): """Initialize the Scenario *s* (required). diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index b323e6c6c..232b948e9 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -189,6 +189,25 @@ def ts_preload(self, ts): """ self.jindex[ts].preloadAllTimeseries() + def ts_get(self, ts): + """Retrieve time-series data.""" + pass + + def ts_set(self, ts, region, variable, data, unit, meta): + """Store time-series data.""" + # Convert *data* to a Java data structure + jdata = java.LinkedHashMap() + for k, v in data.items(): + # Explicit cast is necessary; otherwise java.lang.Long + jdata.put(java.Integer(k), v) + + self.jindex[ts].addTimeseries(region, variable, None, jdata, unit, + meta) + + def ts_delete(self, ts): + """Remove time-series data.""" + pass + # Scenario methods def s_init(self, s, scheme=None, annotation=None): diff --git a/ixmp/core.py b/ixmp/core.py index cc665daac..c57db7e70 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -25,7 +25,6 @@ check_year, harmonize_path, logger, - numcols, ) # %% default settings for column headers @@ -443,51 +442,32 @@ def add_timeseries(self, df, meta=False): specially when :meth:`Scenario.clone` is called for Scenarios created with ``scheme='MESSAGE'``. """ - meta = 1 if meta else 0 + meta = bool(meta) + # Ensure consistent column names df = to_iamc_template(df) - # if in tabular format - if ("value" in df.columns): - df = df.sort_values(by=['region', 'variable', 'unit', 'year'])\ - .reset_index(drop=True) - - region = df.region[0] - variable = df.variable[0] - unit = df.unit[0] - time = None - jData = java.LinkedHashMap() - - for i in df.index: - if not (region == df.region[i] and variable == df.variable[i] - and unit == df.unit[i]): - # if new 'line', pass to Java interface, start a new - # LinkedHashMap - self._jobj.addTimeseries(region, variable, time, jData, - unit, meta) - - region = df.region[i] - variable = df.variable[i] - unit = df.unit[i] - jData = java.LinkedHashMap() - - jData.put(java.Integer(int(df.year[i])), - java.Double(float(df.value[i]))) - # add the final iteration of the loop - self._jobj.addTimeseries(region, variable, time, jData, unit, meta) - - # if in 'IAMC-style' format + if 'value' in df.columns: + # Long format; pivot to wide + df = pd.pivot_table(df, + values='value', + index=['region', 'variable', 'unit'], + columns=['year']) else: - for i in df.index: - jData = java.LinkedHashMap() + # Wide format: set index columns + df.set_index(['region', 'variable', 'unit'], inplace=True) + + # Discard non-numeric columns, e.g. 'model', 'scenario' + num_cols = [pd.api.types.is_numeric_dtype(dt) for dt in df.dtypes] + df = df.iloc[:, num_cols] - for j in numcols(df): - jData.put(java.Integer(int(j)), - java.Double(float(df[j][i]))) + # Columns (year) as integer + df.columns = df.columns.astype(int) - time = None - self._jobj.addTimeseries(df.region[i], df.variable[i], time, - jData, df.unit[i], meta) + # Add one time series per row + for (r, v, u), data in df.iterrows(): + # Values as float; exclude NA + self._backend('set', r, v, data.astype(float).dropna(), u, meta) def timeseries(self, iamc=False, region=None, variable=None, level=None, unit=None, year=None, **kwargs): @@ -1009,6 +989,8 @@ def add_par(self, name, key, val=None, unit=None, comment=None): str(key['unit'][i]), None)) elif isinstance(key, list) and isinstance(key[0], list): + # FIXME filling with non-SI units '???' requires special handling + # later by ixmp.reporting unit = unit or ["???"] * len(key) for i in range(len(key)): if comment and i < len(comment): @@ -1018,6 +1000,8 @@ def add_par(self, name, key, val=None, unit=None, comment=None): elements.append((to_jlist(key[i]), float(val[i]), str(unit[i]), None)) elif isinstance(key, list) and isinstance(val, list): + # FIXME filling with non-SI units '???' requires special handling + # later by ixmp.reporting unit = unit or ["???"] * len(key) for i in range(len(key)): if comment and i < len(comment): @@ -1495,6 +1479,7 @@ def years_active(self, node, tec, yr_vtg): yr_vtg : str vintage year """ + # TODO this is specific to message_ix.Scenario; remove return to_pylist(self._jobj.getTecActYrs(node, tec, str(yr_vtg))) def get_meta(self, name=None): @@ -1530,9 +1515,30 @@ def set_meta(self, name, value): # %% auxiliary functions for class Scenario def to_iamc_template(df): - """Formats a pd.DataFrame to an IAMC-compatible table""" - if "time" in df.columns: - raise("sub-annual time slices not supported by the Python interface!") + """Format pd.DataFrame *df* in IAMC style. + + Parameters + ---------- + df : pandas.DataFrame + May have a 'node' column, which will be renamed to 'region'. + + Returns + ------- + pandas.DataFrame + The returned object has: + + - Any (Multi)Index levels reset as columns. + - Lower-case column names 'region', 'variable', and 'unit'. + + Raises + ------ + ValueError + If 'time' is among the column names; or 'region', 'variable', or 'unit' + is not. + """ + if 'time' in df.columns: + raise ValueError('sub-annual time slices not supported by ' + 'ixmp.TimeSeries') # reset the index if meaningful entries are included there if not list(df.index.names) == [None]: diff --git a/ixmp/utils.py b/ixmp/utils.py index c2bbdc932..29cc1c62e 100644 --- a/ixmp/utils.py +++ b/ixmp/utils.py @@ -96,6 +96,7 @@ def pd_write(df, f, *args, **kwargs): def numcols(df): + """Return the indices of the numeric columns of *df*.""" dtypes = df.dtypes return [i for i in dtypes.index if dtypes.loc[i].name.startswith(('float', 'int'))] From 676998913e5fa0d8d21b521edd4b3142b1da82b6 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2019 14:28:49 -0400 Subject: [PATCH 22/71] Implement JDBCBackend.ts_delete --- ixmp/backend/base.py | 2 +- ixmp/backend/jdbc.py | 30 ++++++++++++------------------ ixmp/core.py | 15 ++++++++------- ixmp/utils.py | 12 ++++++++++++ 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index a7f60af11..adf07e931 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -135,7 +135,7 @@ def ts_set(self, ts, region, variable, data, unit, meta): pass @abstractmethod - def ts_delete(self, ts): + def ts_delete(self, ts, region, variable, years, unit): """Remove time-series data.""" pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 232b948e9..68abd69df 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -1,3 +1,4 @@ +from collections.abc import Collection, Iterable import os from pathlib import Path import re @@ -204,9 +205,10 @@ def ts_set(self, ts, region, variable, data, unit, meta): self.jindex[ts].addTimeseries(region, variable, None, jdata, unit, meta) - def ts_delete(self, ts): + def ts_delete(self, ts, region, variable, years, unit): """Remove time-series data.""" - pass + years = to_jlist2(map(java.Integer, years)) + self.jindex[ts].removeTimeseries(region, variable, None, years, unit) # Scenario methods @@ -471,20 +473,12 @@ def to_jlist(pylist, idx_names=None): def to_jlist2(arg): """Simple conversion of :class:`list` *arg* to JLinkedList.""" jlist = java.LinkedList() - jlist.addAll(arg) + if isinstance(arg, Collection): + # Sized collection can be used directly + jlist.addAll(arg) + elif isinstance(arg, Iterable): + # Transfer items from an iterable, generator, etc. to the LinkedList + [jlist.add(value) for value in arg] + else: + raise ValueError(arg) return jlist - - -# Helper methods - - -def filtered(df, filters): - """Returns a filtered dataframe based on a filters dictionary""" - if filters is None: - return df - - mask = pd.Series(True, index=df.index) - for k, v in filters.items(): - isin = df[k].isin(v) - mask = mask & isin - return df[mask] diff --git a/ixmp/core.py b/ixmp/core.py index c57db7e70..34c54a038 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -15,14 +15,13 @@ # TODO remove these direct imports of Java-related methods from .backend.jdbc import ( - java, to_jlist, to_pylist, - filtered, ) from ixmp.utils import ( as_str_list, check_year, + filtered, harmonize_path, logger, ) @@ -547,15 +546,17 @@ def remove_timeseries(self, df): - `unit` - `year` """ + # Ensure consistent column names df = to_iamc_template(df) + if 'year' not in df.columns: + # Reshape from wide to long format df = pd.melt(df, id_vars=['region', 'variable', 'unit'], var_name='year', value_name='value') - for name, data in df.groupby(['region', 'variable', 'unit']): - years = java.LinkedList() - for y in data['year']: - years.add(java.Integer(y)) - self._jobj.removeTimeseries(name[0], name[1], None, years, name[2]) + + # Remove all years for a given (r, v, u) combination at once + for (r, v, u), data in df.groupby(['region', 'variable', 'unit']): + self._backend('delete', r, v, data['year'].tolist(), u) # %% class Scenario diff --git a/ixmp/utils.py b/ixmp/utils.py index 29cc1c62e..13b9e07e8 100644 --- a/ixmp/utils.py +++ b/ixmp/utils.py @@ -102,6 +102,18 @@ def numcols(df): if dtypes.loc[i].name.startswith(('float', 'int'))] +def filtered(df, filters): + """Returns a filtered dataframe based on a filters dictionary""" + if filters is None: + return df + + mask = pd.Series(True, index=df.index) + for k, v in filters.items(): + isin = df[k].isin(v) + mask = mask & isin + return df[mask] + + def import_timeseries(mp, data, model, scenario, version=None, firstyear=None, lastyear=None): if not isinstance(mp, ixmp.Platform): From 767af5e222ae9994eaed2e3104981338013983d1 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2019 15:11:07 -0400 Subject: [PATCH 23/71] Fix test_get_timeseries_iamc --- tests/test_feature_timeseries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_feature_timeseries.py b/tests/test_feature_timeseries.py index bbc39ff8d..dc0c8c3c5 100644 --- a/tests/test_feature_timeseries.py +++ b/tests/test_feature_timeseries.py @@ -24,7 +24,7 @@ def test_get_timeseries(test_mp): def test_get_timeseries_iamc(test_mp): scen = ixmp.TimeSeries(test_mp, *test_args) - obs = scen.timeseries(iamc=True, regions='World', variables='Testing') + obs = scen.timeseries(region='World', variable='Testing', iamc=True) exp = TS_DF.pivot_table(index=['region', 'variable', 'unit'], columns='year')['value'].reset_index() From f0cda2a7895d3b321ad27abb08be85b1f5859b67 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2019 15:12:12 -0400 Subject: [PATCH 24/71] Implement JDBCBackend.ts_get --- ixmp/backend/base.py | 24 ++++++++++++++++++++-- ixmp/backend/jdbc.py | 24 ++++++++++++++++++---- ixmp/core.py | 48 +++++++++++--------------------------------- 3 files changed, 54 insertions(+), 42 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index adf07e931..19a5d7252 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -8,6 +8,7 @@ 'is_locked', 'cre_user', 'cre_date', 'upd_user', 'upd_date', 'lock_user', 'lock_date', 'annotation', 'version'), + 'ts_get': ('region', 'variable', 'unit', 'year', 'value'), } @@ -115,8 +116,27 @@ def ts_commit(self, ts, comment): pass @abstractmethod - def ts_get(self, ts): - """Retrieve time-series data.""" + def ts_get(self, ts, region, variable, unit, year): + """Retrieve time-series data. + + Parameters + ---------- + region : list of str + variable : list of str + unit : list of str + year : list of str + + Yields + ------ + tuple + The five members of each tuple are: + + 1. region: str. + 2. variable: str. + 3. unit: str. + 4. year: int. + 5. value: float. + """ pass @abstractmethod diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 68abd69df..e87740913 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -190,9 +190,21 @@ def ts_preload(self, ts): """ self.jindex[ts].preloadAllTimeseries() - def ts_get(self, ts): + def ts_get(self, ts, region, variable, unit, year): """Retrieve time-series data.""" - pass + r = to_jlist2(region) + v = to_jlist2(variable) + u = to_jlist2(unit) + y = to_jlist2(year) + + ftype = { + 'year': int, + 'value': float, + } + + for row in self.jindex[ts].getTimeseries(r, v, u, None, y): + yield tuple(ftype.get(f, str)(row.get(f)) + for f in FIELDS['ts_get']) def ts_set(self, ts, region, variable, data, unit, meta): """Store time-series data.""" @@ -207,7 +219,7 @@ def ts_set(self, ts, region, variable, data, unit, meta): def ts_delete(self, ts, region, variable, years, unit): """Remove time-series data.""" - years = to_jlist2(map(java.Integer, years)) + years = to_jlist2(years, java.Integer) self.jindex[ts].removeTimeseries(region, variable, None, years, unit) # Scenario methods @@ -470,9 +482,13 @@ def to_jlist(pylist, idx_names=None): return jList -def to_jlist2(arg): +def to_jlist2(arg, convert=None): """Simple conversion of :class:`list` *arg* to JLinkedList.""" jlist = java.LinkedList() + + if convert: + arg = map(convert, arg) + if isinstance(arg, Collection): # Sized collection can be used directly jlist.addAll(arg) diff --git a/ixmp/core.py b/ixmp/core.py index 34c54a038..70b1c32e0 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -468,8 +468,8 @@ def add_timeseries(self, df, meta=False): # Values as float; exclude NA self._backend('set', r, v, data.astype(float).dropna(), u, meta) - def timeseries(self, iamc=False, region=None, variable=None, level=None, - unit=None, year=None, **kwargs): + def timeseries(self, region=None, variable=None, unit=None, year=None, + iamc=False): """Retrieve TimeSeries data. Parameters @@ -491,44 +491,20 @@ def timeseries(self, iamc=False, region=None, variable=None, level=None, :class:`pandas.DataFrame` Specified data. """ - # convert filter lists to Java objects - region = to_jlist(region) - variable = to_jlist(variable) - unit = to_jlist(unit) - year = to_jlist(year) - - # retrieve data, convert to pandas.DataFrame - data = self._jobj.getTimeseries(region, variable, unit, None, year) - dictionary = {} - - # if in tabular format - ts_range = range(data.size()) - - cols = ['region', 'variable', 'unit'] - for i in cols: - dictionary[i] = [str(data.get(j).get(i)) for j in ts_range] - - dictionary['year'] = [data.get(j).get('year').intValue() - for j in ts_range] - cols.append("year") - - dictionary['value'] = [data.get(j).get('value').floatValue() - for j in ts_range] - cols.append("value") - - df = pd.DataFrame - df = df.from_dict(dictionary, orient='columns', dtype=None) - + # Retrieve data, convert to pandas.DataFrame + df = pd.DataFrame(self._backend('get', + as_str_list(region) or [], + as_str_list(variable) or [], + as_str_list(unit) or [], + as_str_list(year) or []), + columns=FIELDS['ts_get']) df['model'] = self.model df['scenario'] = self.scenario - df = df[['model', 'scenario'] + cols] - if iamc: - df = df.pivot_table(index=IAMC_IDX, columns='year')['value'] - df.reset_index(inplace=True) - df.columns = [c if isinstance(c, str) else int(c) - for c in df.columns] + # Convert to wide format + df = df.pivot_table(index=IAMC_IDX, columns='year')['value'] \ + .reset_index() df.columns.names = [None] return df From 26d0c979c4fdeccb1f772002eddfe7bf2056024e Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2019 15:27:52 -0400 Subject: [PATCH 25/71] Remove direct imports from ixmp.backend.jdbc in ixmp.core --- ixmp/backend/jdbc.py | 2 +- ixmp/core.py | 33 ++++++++++++++------------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index e87740913..481724ddd 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -454,7 +454,7 @@ def start_jvm(jvmargs=None): # Conversion methods def to_pylist(jlist): - """Transforms a Java.Array or Java.List to a python list""" + """Transforms a Java.Array or Java.List to a :class:`numpy.array`.""" # handling string array try: return np.array(jlist[:]) diff --git a/ixmp/core.py b/ixmp/core.py index 70b1c32e0..e4983867f 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -12,13 +12,7 @@ from ixmp import model_settings from .backend import BACKENDS, FIELDS - -# TODO remove these direct imports of Java-related methods -from .backend.jdbc import ( - to_jlist, - to_pylist, -) -from ixmp.utils import ( +from .utils import ( as_str_list, check_year, filtered, @@ -955,13 +949,13 @@ def add_par(self, name, key, val=None, unit=None, comment=None): idx_names = self.idx_names(name) if "comment" in list(key): for i in key.index: - elements.append((to_jlist(key.loc[i], idx_names), + elements.append((as_str_list(key.loc[i], idx_names), float(key['value'][i]), str(key['unit'][i]), str(key['comment'][i]))) else: for i in key.index: - elements.append((to_jlist(key.loc[i], idx_names), + elements.append((as_str_list(key.loc[i], idx_names), float(key['value'][i]), str(key['unit'][i]), None)) @@ -971,10 +965,10 @@ def add_par(self, name, key, val=None, unit=None, comment=None): unit = unit or ["???"] * len(key) for i in range(len(key)): if comment and i < len(comment): - elements.append((to_jlist(key[i]), float(val[i]), + elements.append((as_str_list(key[i]), float(val[i]), str(unit[i]), str(comment[i]))) else: - elements.append((to_jlist(key[i]), float(val[i]), + elements.append((as_str_list(key[i]), float(val[i]), str(unit[i]), None)) elif isinstance(key, list) and isinstance(val, list): # FIXME filling with non-SI units '???' requires special handling @@ -988,7 +982,7 @@ def add_par(self, name, key, val=None, unit=None, comment=None): elements.append((str(key[i]), float(val[i]), str(unit[i]), None)) elif isinstance(key, list) and not isinstance(val, list): - elements.append((to_jlist(key), float(val), unit, comment)) + elements.append((as_str_list(key), float(val), unit, comment)) else: elements.append((str(key), float(val), unit, comment)) @@ -1240,7 +1234,8 @@ def read_sol_from_gdx(self, path, filename, comment=None, """ self.clear_cache() # reset Python data cache self._jobj.readSolutionFromGDX(path, filename, comment, - to_jlist(var_list), to_jlist(equ_list), + as_str_list(var_list), + as_str_list(equ_list), check_solution) def has_solution(self): @@ -1456,8 +1451,8 @@ def years_active(self, node, tec, yr_vtg): yr_vtg : str vintage year """ - # TODO this is specific to message_ix.Scenario; remove - return to_pylist(self._jobj.getTecActYrs(node, tec, str(yr_vtg))) + # TODO this is specific to message_ix.Scenario AND is untested; remove + return list(self._jobj.getTecActYrs(node, tec, str(yr_vtg))) def get_meta(self, name=None): """get scenario metadata @@ -1537,19 +1532,19 @@ def _remove_ele(item, key): """auxiliary """ if item.getDim() > 0: if isinstance(key, list) or isinstance(key, pd.Series): - item.removeElement(to_jlist(key)) + item.removeElement(as_str_list(key)) elif isinstance(key, pd.DataFrame) or isinstance(key, dict): if isinstance(key, dict): key = pd.DataFrame.from_dict(key, orient='columns', dtype=None) - idx_names = to_pylist(item.getIdxNames()) + idx_names = list(item.getIdxNames()) for i in key.index: - item.removeElement(to_jlist(key.loc[i], idx_names)) + item.removeElement(as_str_list(key.loc[i], idx_names)) else: item.removeElement(str(key)) else: if isinstance(key, list) or isinstance(key, pd.Series): - item.removeElement(to_jlist(key)) + item.removeElement(as_str_list(key)) else: item.removeElement(str(key)) From 9a5869e4233f5fe3cbed8a4a7e684f030902a38c Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2019 15:30:00 -0400 Subject: [PATCH 26/71] Tidy warn() import in ixmp.core --- ixmp/core.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/ixmp/core.py b/ixmp/core.py index e4983867f..53b110fef 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -4,7 +4,6 @@ import logging import os from subprocess import check_call -import warnings from warnings import warn import numpy as np @@ -163,8 +162,8 @@ def Scenario(self, model, scen, version=None, >>> ixmp.Scenario(mp, …) """ - warnings.warn('The constructor `mp.Scenario()` is deprecated, ' - 'please use `ixmp.Scenario(mp, ...)`') + warn('The constructor `mp.Scenario()` is deprecated, please use ' + '`ixmp.Scenario(mp, ...)`') return Scenario(self, model, scen, version, scheme, annotation, cache) @@ -611,8 +610,8 @@ def __init__(self, mp, model, scenario, version=None, scheme=None, self._backend('init', scheme, annotation) if self.scheme == 'MESSAGE' and not hasattr(self, 'is_message_scheme'): - warnings.warn('Using `ixmp.Scenario` for MESSAGE-scheme scenarios ' - 'is deprecated, please use `message_ix.Scenario`') + warn('Using `ixmp.Scenario` for MESSAGE-scheme scenarios is ' + 'deprecated, please use `message_ix.Scenario`') # Initialize cache self._cache = cache @@ -1161,15 +1160,13 @@ def clone(self, model=None, scenario=None, annotation=None, Platform to clone to (default: current platform) """ if 'keep_sol' in kwargs: - warnings.warn( - '`keep_sol` is deprecated and will be removed in the next' + - ' release, please use `keep_solution`') + warn('`keep_sol` is deprecated and will be removed in the next' + ' release, please use `keep_solution`') keep_solution = kwargs.pop('keep_sol') if 'scen' in kwargs: - warnings.warn( - '`scen` is deprecated and will be removed in the next' + - ' release, please use `scenario`') + warn('`scen` is deprecated and will be removed in the next' + ' release, please use `scenario`') scenario = kwargs.pop('scen') if keep_solution and first_model_year is not None: @@ -1399,9 +1396,8 @@ def callback(scenario, **kwargs): cb_result = callback(self, **cb_kwargs) if cb_result is None and warn_none: - warnings.warn('solve(callback=...) argument returned None;' - ' will loop indefinitely unless True is' - ' returned.') + warn('solve(callback=...) argument returned None; will loop ' + 'indefinitely unless True is returned.') # Don't repeat the warning warn_none = False From c7f63e79264f6f2596fa98903e0184252d703a01 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2019 15:36:35 -0400 Subject: [PATCH 27/71] Move ._jobj() shim from TimeSeries to Scenario TimeSeries is clean of direct Java references --- ixmp/core.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ixmp/core.py b/ixmp/core.py index 53b110fef..fd488303b 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -347,14 +347,6 @@ def __init__(self, mp, model, scenario, version=None, annotation=None): self.platform = mp self._backend('init', annotation) - @property - def _jobj(self): - """Shim to allow existing code that references ._jobj to work.""" - # TODO address all such warnings, then remove - loc = inspect.stack()[1].function - warn(f'Accessing {self.__class__.__name__}._jobj in {loc}') - return self.platform._backend.jindex[self] - def _backend(self, method, *args, **kwargs): """Convenience for calling *method* on the backend.""" func = getattr(self.platform._backend, f'ts_{method}') @@ -617,6 +609,14 @@ def __init__(self, mp, model, scenario, version=None, scheme=None, self._cache = cache self._pycache = {} + @property + def _jobj(self): + """Shim to allow existing code that references ._jobj to work.""" + # TODO address all such warnings, then remove + loc = inspect.stack()[1].function + warn(f'Accessing Scenario._jobj in {loc}') + return self.platform._backend.jindex[self] + def _backend(self, method, *args, **kwargs): """Convenience for calling *method* on the backend.""" try: From a73a96def72aa468527b4689c5b011c89f0cc51f Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Wed, 21 Aug 2019 10:19:06 +0200 Subject: [PATCH 28/71] Appease stickler --- ixmp/backend/jdbc.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 481724ddd..9813a6b2a 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -24,7 +24,8 @@ 'NOTSET': 'OFF', } -# Java classes, loaded by start_jvm() +# Java classes, loaded by start_jvm(). These become available as e.g. +# java.IxException or java.HashMap. java = SimpleNamespace() JAVA_CLASSES = [ @@ -36,7 +37,7 @@ 'java.util.HashMap', 'java.util.LinkedHashMap', 'java.util.LinkedList', - ] +] class JDBCBackend(Backend): From 7d98df3281f2464d1dc8923e22e503b06ac4f0e6 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Wed, 21 Aug 2019 10:20:10 +0200 Subject: [PATCH 29/71] Appease stickler 2 --- ixmp/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ixmp/core.py b/ixmp/core.py index fd488303b..1b8fcd2eb 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -78,7 +78,7 @@ class Platform: _backend_direct = [ 'open_db', 'close_db', - ] + ] def __init__(self, *args, backend='jdbc', **backend_args): if backend != 'jdbc': From 37ab47a8e766238825e2caefc2a15fa64b9c4d91 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 21:33:49 +0200 Subject: [PATCH 30/71] Add ixmp.model.gams.GAMSModel --- ixmp/__init__.py | 11 ---- ixmp/core.py | 113 ++++-------------------------------- ixmp/model/__init__.py | 15 +++++ ixmp/model/base.py | 12 ++++ ixmp/model/gams.py | 129 +++++++++++++++++++++++++++++++++++++++++ ixmp/model_settings.py | 48 --------------- 6 files changed, 166 insertions(+), 162 deletions(-) create mode 100644 ixmp/model/__init__.py create mode 100644 ixmp/model/base.py create mode 100644 ixmp/model/gams.py delete mode 100644 ixmp/model_settings.py diff --git a/ixmp/__init__.py b/ixmp/__init__.py index 7c4391cf7..c9dd5b00f 100644 --- a/ixmp/__init__.py +++ b/ixmp/__init__.py @@ -7,15 +7,4 @@ TimeSeries, Scenario, ) - -from ixmp.model_settings import ModelConfig, register_model # noqa: E402 from ixmp.reporting import Reporter # noqa: F401 - - -register_model( - 'default', - ModelConfig(model_file='"{model}.gms"', - inp='{model}_in.gdx', - outp='{model}_out.gdx', - args=['--in="{inp}"', '--out="{outp}"']) -) diff --git a/ixmp/core.py b/ixmp/core.py index 7565ed06b..6f084cc9d 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -2,20 +2,17 @@ import inspect from itertools import repeat, zip_longest import logging -import os -from subprocess import check_call from warnings import warn import numpy as np import pandas as pd -from ixmp import model_settings from .backend import BACKENDS, FIELDS +from .model import get_model from .utils import ( as_str_list, check_year, filtered, - harmonize_path, logger, ) @@ -1291,46 +1288,6 @@ def clone(self, model=None, scenario=None, annotation=None, return scenario_class(platform, model, scenario, cache=self._cache, version=jclone) - def to_gdx(self, path, filename, include_var_equ=False): - """export the scenario data to GAMS gdx - - Parameters - ---------- - path : str - path to the folder - filename : str - name of the gdx file - include_var_equ : boolean, optional - indicator whether to include variables/equations in gdx - """ - self._jobj.toGDX(path, filename, include_var_equ) - - def read_sol_from_gdx(self, path, filename, comment=None, - var_list=None, equ_list=None, check_solution=True): - """read solution from GAMS gdx and import it to the scenario - - Parameters - ---------- - path : str - path to the folder - filename : str - name of the gdx file - comment : str - comment to be added to the changelog - var_list : list of str - variables (levels and marginals) to be imported from gdx - equ_list : list of str - equations (levels and marginals) to be imported from gdx - check_solution : boolean, optional - raise an error if GAMS did not solve to optimality - (only applicable for a MESSAGE-scheme scenario) - """ - self.clear_cache() # reset Python data cache - self._jobj.readSolutionFromGDX(path, filename, comment, - as_str_list(var_list), - as_str_list(equ_list), - check_solution) - def has_solution(self): """Return :obj:`True` if the Scenario has been solved. @@ -1365,17 +1322,13 @@ def remove_solution(self, first_model_year=None): else: raise ValueError('This Scenario does not have a solution!') - def solve(self, model, case=None, model_file=None, in_file=None, - out_file=None, solve_args=None, comment=None, var_list=None, - equ_list=None, check_solution=True, callback=None, - gams_args=['LogOption=4'], cb_kwargs={}): + def solve(self, model=None, callback=None, cb_kwargs={}, **model_options): """Solve the model and store output. - ixmp 'solves' a model using the following steps: - - 1. Write all Scenario data to a GDX model input file. - 2. Run GAMS for the specified `model` to perform calculations. - 3. Read the model output, or 'solution', into the database. + ixmp 'solves' a model by invoking the ``run`` method of a + :class:`BaseModel` subclass—for instance, :meth:`GAMSModel.run`. + Depending on the underlying optimization software, different steps are + taken; see each model class for details. If the optional argument `callback` is given, then additional steps are performed: @@ -1438,27 +1391,8 @@ def solve(self, model, case=None, model_file=None, in_file=None, raise ValueError('This Scenario has already been solved, ', 'use `remove_solution()` first!') - model = str(harmonize_path(model)) - config = model_settings.model_config(model) \ - if model_settings.model_registered(model) \ - else model_settings.model_config('default') - - # define case name for gdx export/import, replace spaces by '_' - case = case or '{}_{}'.format(self.model, self.scenario) - case = case.replace(" ", "_") - - model_file = model_file or config.model_file.format(model=model) - - # define paths for writing to gdx, running GAMS, and reading a solution - inp = in_file or config.inp.format(model=model, case=case) - outp = out_file or config.outp.format(model=model, case=case) - args = solve_args or [arg.format(model=model, case=case, inp=inp, - outp=outp) for arg in config.args] - - ipth = os.path.dirname(inp) - ingdx = os.path.basename(inp) - opth = os.path.dirname(outp) - outgdx = os.path.basename(outp) + # Instantiate a model + model = get_model(model, **model_options) # Validate *callback* argument if callback is not None and not callable(callback): @@ -1468,19 +1402,12 @@ def solve(self, model, case=None, model_file=None, in_file=None, def callback(scenario, **kwargs): return True + # Flag to warn if the *callback* appears not to return anything warn_none = True # Iterate until convergence while True: - # Write model data to file - self.to_gdx(ipth, ingdx) - - # Invoke GAMS - run_gams(model_file, args, gams_args=gams_args) - - # Read model solution - self.read_sol_from_gdx(opth, outgdx, comment, - var_list, equ_list, check_solution) + model.run(self) # Store an iteration number to help the callback if not hasattr(self, 'iteration'): @@ -1639,23 +1566,3 @@ def _remove_ele(item, key): item.removeElement(as_str_list(key)) else: item.removeElement(str(key)) - - -def run_gams(model_file, args, gams_args=['LogOption=4']): - """Parameters - ---------- - model : str - the path to the gams file - args : list - arguments related to the GAMS code (input/output gdx paths, etc.) - gams_args : list of str - additional arguments for the CLI call to gams - - `LogOption=4` prints output to stdout (not console) and the log file - """ - cmd = ['gams', model_file] + args + gams_args - cmd = cmd if os.name != 'nt' else ' '.join(cmd) - - file_path = os.path.dirname(model_file).strip('"') - file_path = None if file_path == '' else file_path - - check_call(cmd, shell=os.name == 'nt', cwd=file_path) diff --git a/ixmp/model/__init__.py b/ixmp/model/__init__.py new file mode 100644 index 000000000..12c7d55b3 --- /dev/null +++ b/ixmp/model/__init__.py @@ -0,0 +1,15 @@ +from .gams import GAMSModel + + +#: Mapping from names to available backends +MODELS = { + 'default': GAMSModel, + 'gams': GAMSModel, +} + + +def get_model(name, **args): + try: + return MODELS[name](**args) + except KeyError: + return MODELS['default'](name=name, **args) diff --git a/ixmp/model/base.py b/ixmp/model/base.py new file mode 100644 index 000000000..2de6f16eb --- /dev/null +++ b/ixmp/model/base.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class Model(ABC): + @abstractmethod + def __init__(self, name, **kwargs): + pass + + @abstractmethod + def run(self, scenario): + """Execute the model.""" + pass diff --git a/ixmp/model/gams.py b/ixmp/model/gams.py new file mode 100644 index 000000000..96a14af2d --- /dev/null +++ b/ixmp/model/gams.py @@ -0,0 +1,129 @@ +import os +from pathlib import Path +from subprocess import check_call + + +from ixmp.model.base import Model +from ixmp.utils import as_str_list + + +class GAMSModel(Model): + """General class for ixmp models using GAMS solvers. + + GAMSModel solves a Scenario using the following steps: + + 1. All Scenario data is written to a model input file in GDX format. + 2. A GAMS program is run to perform calculations. + 3. Output, or solution, data is read and stored in the Scenario. + + When created and then :meth:`run`, GAMSModel constructs file paths and + other necessary values using format strings. The default settings are those + in :attr:`config`; these may be overridden by the keyword arguments to + the constructor. + + Other parameters + ---------------- + name : str + Override the :attr:`name` attribute. + model_file : str + Path to GAMS file, including '.gms' extension. + case : str + Run or case identifier to use in GDX file names. Default: + '{scenario.model}_{scenario.name}', where *scenario* is the Scenario + object passed to :meth:`run`. Formatted using *model_file* and + *scenario*. + in_file : str + Path to write GDX input file. Formatted using *model_file*, + *scenario*, and *case*. + out_file : str + Path to read GDX output file. Formatted using *model_file*, + *scenario*, and *case*. + solve_args : list of str + Arguments to be passed to GAMS. Each formatted using *model_file*, + *scenario*, *case*, *in_file*, and *out_file*. + gams_args : list of str + Additional arguments passed directly to GAMS. See, e.g., + https://www.gams.com/latest/docs/UG_GamsCall.html#UG_GamsCall_ListOfCommandLineParameters + + - “LogOption=4” prints output to stdout (not console) and the log + file. + comment : str + Comment added to Scenario when importing the solution. + var_list : list of str + Variables to be imported from the *out_file*. + equ_list : list of str + Equations to be imported from the *out_file*. + check_solution : bool + If True, raise an exception if the GAMS solver did not reach + optimality. (Only for MESSAGE-scheme Scenarios.) + """ + + #: Model name. + name = 'default' + + #: Default model options. + defaults = { + 'model_file': '{model_name}.gms', + 'case': "{scenario.model}_{scenario.scenario}", + 'in_file': '{model_name}_in.gdx', + 'out_file': '{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, + } + + def __init__(self, name=None, **model_options): + self.model_name = name or self.name + for arg_name, default in self.defaults.items(): + setattr(self, arg_name, model_options.get(arg_name, default)) + + def run(self, scenario): + """Execute the model.""" + self.scenario = scenario + + def format(key): + value = getattr(self, key) + try: + return value.format(**self.__dict__) + except AttributeError: + # Something like a Path; don't format it + return value + + # Process args in order + command = ['gams'] + + model_file = Path(format('model_file')).resolve() + command.append('"{}"'.format(model_file)) + + self.case = format('case').replace(' ', '_') + self.in_file = Path(format('in_file')).resolve() + self.out_file = Path(format('out_file')).resolve() + + for arg in self.solve_args: + command.append(arg.format(**self.__dict__)) + + command.extend(self.gams_args) + + if os.name == 'nt': + command = ' '.join(command) + + # Write model data to file + # include_var_equ=False -> do not include variables/equations in GDX + scenario._jobj.toGDX(str(self.in_file.parent), self.in_file.name, + False) + + # Invoke GAMS + check_call(command, shell=os.name == 'nt', cwd=model_file.parent) + + # Reset Python data cache + scenario.clear_cache() + + # Read model solution + scenario._jobj.readSolutionFromGDX( + str(self.out_file.parent), self.out_file.name, self.comment, + as_str_list(self.var_list), as_str_list(self.equ_list), + self.check_solution) diff --git a/ixmp/model_settings.py b/ixmp/model_settings.py deleted file mode 100644 index 8ad7dd1b3..000000000 --- a/ixmp/model_settings.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- - -from collections import namedtuple - -ModelConfig = namedtuple('ModelConfig', ['model_file', 'inp', 'outp', 'args']) - -_MODEL_REGISTRY = {} - - -def register_model(name, config): - """Register a new model with ixmp. - - Parameters - ---------- - name : str - Model name. - config : dict - Configuration settings for the model, with the following keys. Each key - is a format string that may use certain named values: - - - `model_file` (:class:`str`): GAMS source file (``.gms``) containing - the model. E.g. "{model}_run.gms". Available values: model, case. - - `inp` (:class:`str`): input path; location where the model expects a - GDX file containing input data. E.g. "{model}_{case}_in.gdx". - Available values: model, case. - - `outp` (:class:`str`): output path; location where the model will - create a GDX file containing output data. E.g. - "{model}_{case}_out.gdx". Available values: model, case. - - `args` (:class:`list` of :class:`str`): additional GAMS command-line - args to be passed when invoking the model. Available values: model, - case, inp, outp. - - The `model` and `case` formatting values are generated from - :attr:`ixmp.Scenario.model` and :attr:`ixmp.Scenario.scenario`, - respectively, with spaces (“ ”) converted to underscores ("_"). - """ - global _MODEL_REGISTRY - _MODEL_REGISTRY[name] = config - - -def model_registered(name): - global _MODEL_REGISTRY - return name in _MODEL_REGISTRY - - -def model_config(name): - global _MODEL_REGISTRY - return _MODEL_REGISTRY[name] From ddc8b216bd1542fb428e26401a56ef87612e3f94 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 21:34:34 +0200 Subject: [PATCH 31/71] .gitignore more coverage-related files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bd1177490..3a48aad59 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,7 @@ rixmp/source/.Rhistory # pytest and related *.pid -.coverage +.coverage* .pytest_cache/ coverage.xml htmlcov/ From 4e07a0ec404030a7cabc0b584982aad86ed48e1e Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 22:47:28 +0200 Subject: [PATCH 32/71] Add ts_get_geo, ts_set_geo, ts_delete_geo to Backend --- ixmp/backend/base.py | 43 +++++++++++++++++++++++++++++ ixmp/backend/jdbc.py | 66 ++++++++++++++++++++++++++++++++++++++++++++ ixmp/core.py | 63 ++++++------------------------------------ 3 files changed, 118 insertions(+), 54 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 19a5d7252..779320f65 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -9,6 +9,8 @@ 'upd_date', 'lock_user', 'lock_date', 'annotation', 'version'), 'ts_get': ('region', 'variable', 'unit', 'year', 'value'), + 'ts_get_geo': ('region', 'variable', 'time', 'year', 'value', 'unit', + 'meta'), } @@ -139,6 +141,25 @@ def ts_get(self, ts, region, variable, unit, year): """ pass + @abstractmethod + def ts_get_geo(self, ts): + """Retrieve time-series 'geodata'. + + Yields + ------ + tuple + The seven members of each tuple are: + + 1. region: str. + 2. variable: str. + 3. time: str. + 4. year: int. + 5. value: str. + 6. unit: str. + 7. meta: int. + """ + pass + @abstractmethod def ts_set(self, ts, region, variable, data, unit, meta): """Store time-series data. @@ -154,11 +175,33 @@ def ts_set(self, ts, region, variable, data, unit, meta): """ pass + @abstractmethod + def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): + """Store time-series 'geodata'. + + Parameters + ---------- + region, variable, time, unit : str + Indices for the data. + year : int + Year index. + value : str + Data. + meta : bool + Metadata flag. + """ + pass + @abstractmethod def ts_delete(self, ts, region, variable, years, unit): """Remove time-series data.""" pass + @abstractmethod + def ts_delete_geo(self, ts, region, variable, time, years, unit): + """Remove time-series 'geodata'.""" + pass + @abstractmethod def s_init(self, s, annotation=None): """Initialize the Scenario *s* (required). diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 9813a6b2a..e55765b93 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -1,4 +1,6 @@ +from collections import ChainMap from collections.abc import Collection, Iterable +from functools import lru_cache import os from pathlib import Path import re @@ -193,20 +195,67 @@ def ts_preload(self, ts): def ts_get(self, ts, region, variable, unit, year): """Retrieve time-series data.""" + # Convert the selectors to Java lists r = to_jlist2(region) v = to_jlist2(variable) u = to_jlist2(unit) y = to_jlist2(year) + # Field types ftype = { 'year': int, 'value': float, } + # Iterate over returned rows for row in self.jindex[ts].getTimeseries(r, v, u, None, y): + # Get the value of each field and maybe convert its type yield tuple(ftype.get(f, str)(row.get(f)) for f in FIELDS['ts_get']) + def ts_get_geo(self, ts): + """Retrieve time-series 'geodata'.""" + # NB the return type of getGeoData() requires more processing than + # getTimeseries. It also accepts no selectors. + + # Field types + ftype = { + 'meta': int, + 'time': lambda ord: timespans()[int(ord)], # Look up the name + 'year': lambda obj: obj, # Pass through; handled later + } + + # Returned names in Java data structure do not match API column names + jname = { + 'meta': 'meta', + 'region': 'nodeName', + 'time': 'time', + 'unit': 'unitName', + 'variable': 'keyString', + 'year': 'yearlyData' + } + + # Iterate over rows from the Java backend + for row in self.jindex[ts].getGeoData(): + data1 = {f: ftype.get(f, str)(row.get(jname.get(f, f))) + for f in FIELDS['ts_get_geo'] if f != 'value'} + + # At this point, the 'year' key is a not a single value, but a + # year -> value mapping with multiple entries + yv_entries = data1.pop('year').entrySet() + + # Construct a chain map: look up in data1, then data2 + data2 = {'year': None, 'value': None} + cm = ChainMap(data1, data2) + + for yv in yv_entries: + # Update data2 + data2['year'] = yv.getKey() + data2['value'] = yv.getValue() + + # Construct a row with a single value + yield tuple(cm[f] for f in FIELDS['ts_get_geo']) + def ts_set(self, ts, region, variable, data, unit, meta): """Store time-series data.""" # Convert *data* to a Java data structure @@ -218,11 +267,21 @@ def ts_set(self, ts, region, variable, data, unit, meta): self.jindex[ts].addTimeseries(region, variable, None, jdata, unit, meta) + def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): + """Store time-series 'geodata'.""" + self.jindex[ts].addGeoData(region, variable, time, java.Integer(year), + value, unit, meta) + def ts_delete(self, ts, region, variable, years, unit): """Remove time-series data.""" years = to_jlist2(years, java.Integer) self.jindex[ts].removeTimeseries(region, variable, None, years, unit) + def ts_delete_geo(self, ts, region, variable, time, years, unit): + """Remove time-series 'geodata'.""" + years = to_jlist2(years, java.Integer) + self.jindex[ts].removeGeoData(region, variable, time, years, unit) + # Scenario methods def s_init(self, s, scheme=None, annotation=None): @@ -499,3 +558,10 @@ def to_jlist2(arg, convert=None): else: raise ValueError(arg) return jlist + + +@lru_cache(1) +def timespans(): + # Mapping for the enums of at.ac.iiasa.ixmp.objects.TimeSeries.TimeSpan + jTimeSpan = JClass('at.ac.iiasa.ixmp.objects.TimeSeries$TimeSpan') + return {t.ordinal(): t.name() for t in jTimeSpan.values()} diff --git a/ixmp/core.py b/ixmp/core.py index 6f084cc9d..d1340ed5c 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -533,14 +533,9 @@ def add_geodata(self, df): - `value` - `meta` """ - for i in df.index: - self._jobj.addGeoData(df.region[i], - df.variable[i], - df.time[i], - java.Integer(int(df.year[i])), - df.value[i], - df.unit[i], - int(df.meta[i])) + for _, row in df.astype({'year': int, 'meta': int}).iterrows(): + self._backend('set_geo', row.region, row.variable, row.time, + row.year, row.value, row.unit, row.meta) def remove_geodata(self, df): """Remove geodata from the TimeSeries instance. @@ -556,26 +551,10 @@ def remove_geodata(self, df): - `time` - `year` """ - for values, _df in df.groupby(['region', 'variable', 'time', 'unit']): - years = java.LinkedList() - for year in _df['year']: - years.add(java.Integer(year)) - self._jobj.removeGeoData(values[0], values[1], values[2], years, - values[3]) - - def _get_timespans(self): - self._timespans = {} - timespan_cls = JClass('at.ac.iiasa.ixmp.objects.TimeSeries$TimeSpan') - for timespan in timespan_cls.values(): - self._timespans[timespan.ordinal()] = timespan.name() - - def _get_timespan_name(self, ordinal): - """Access the enums of at.ac.iiasa.ixmp.objects.TimeSeries.TimeSpan""" - if isinstance(ordinal, str): - ordinal = int(ordinal) - if not self._timespans: - self._get_timespans() - return self._timespans[ordinal] + # Remove all years for a given (r, v, t, u) combination at once + for (r, v, t, u), data in df.groupby(['region', 'variable', 'time', + 'unit']): + self._backend('delete_geo', r, v, t, data['year'].tolist(), u) def get_geodata(self): """Fetch geodata and return it as dataframe. @@ -585,32 +564,8 @@ def get_geodata(self): :class:`pandas.DataFrame` Specified data. """ - java_geodata = self._jobj.getGeoData() - geodata_range = range(java_geodata.size()) - # auto_cols can be added without any conversion - auto_cols = {'nodeName': 'region', - 'unitName': 'unit', - 'meta': 'meta', - 'time': 'time', - 'keyString': 'variable'} - # Initialize empty dict to have correct columns also for empty results - cols = list(auto_cols.values()) + ['year', 'value'] - geodata = {col: [] for col in cols} - - for i in geodata_range: - year_values = java_geodata.get(i).get('yearlyData').entrySet() - for year_value in year_values: - geodata['year'].append(year_value.getKey()) - geodata['value'].append(year_value.getValue()) - for (java_key, python_key) in auto_cols.items(): - geodata[python_key].append(str(java_geodata.get(i).get( - java_key))) - - geodata['meta'] = [int(_meta) for _meta in geodata['meta']] - geodata['time'] = [self._get_timespan_name(time) for time in - geodata['time']] - - return pd.DataFrame.from_dict(geodata) + return pd.DataFrame(self._backend('get_geo'), + columns=FIELDS['ts_get_geo']) # %% class Scenario From e94fa0d9bc98d45f1dc22b6ded1ed2573926ae9a Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 23:05:24 +0200 Subject: [PATCH 33/71] Check backend type in GAMSModel.run() --- ixmp/core.py | 1 - ixmp/model/gams.py | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ixmp/core.py b/ixmp/core.py index d1340ed5c..55334eb99 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -82,7 +82,6 @@ def __init__(self, *args, backend='jdbc', **backend_args): raise ValueError(f'unknown ixmp backend {backend!r}') else: # Copy positional args for the default JDBC backend - print(args, backend_args) for i, arg in enumerate(['dbprops', 'dbtype', 'jvmargs']): if len(args) > i: backend_args[arg] = args[i] diff --git a/ixmp/model/gams.py b/ixmp/model/gams.py index 96a14af2d..5d198a075 100644 --- a/ixmp/model/gams.py +++ b/ixmp/model/gams.py @@ -3,6 +3,7 @@ from subprocess import check_call +from ixmp.backend.jdbc import JDBCBackend from ixmp.model.base import Model from ixmp.utils import as_str_list @@ -83,6 +84,10 @@ def __init__(self, name=None, **model_options): def run(self, scenario): """Execute the model.""" + if not isinstance(scenario.platform._backend, JDBCBackend): + raise ValueError('GAMSModel can only solve Scenarios with ' + 'JDBCBackend') + self.scenario = scenario def format(key): From 113cf3347a9cfe71d634ba4ead776e3c29d18063 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 23:14:55 +0200 Subject: [PATCH 34/71] Move GDX-related calls to JDBCBackend --- ixmp/backend/jdbc.py | 24 ++++++++++++++++++++++++ ixmp/model/gams.py | 24 ++++++++++++------------ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index e55765b93..21945b998 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -444,6 +444,30 @@ def s_add_par_values(self, s, name, elements): # Helpers; not part of the Backend interface + def s_write_gdx(self, s, path): + """Write the Scenario to a GDX file at *path*.""" + # include_var_equ=False -> do not include variables/equations in GDX + self.jindex[s].toGDX(str(path.parent), path.name, False) + + def s_read_gdx(self, s, path, check_solution, comment, equ_list, var_list): + """Read the Scenario from a GDX file at *path*. + + Parameters + ---------- + check_solution : bool + If True, raise an exception if the GAMS solver did not reach + optimality. (Only for MESSAGE-scheme Scenarios.) + comment : str + Comment added to Scenario when importing the solution. + equ_list : list of str + Equations to be imported. + var_list : list of str + Variables to be imported. + """ + self.jindex[s].readSolutionFromGDX( + str(path.parent), path.name, comment, var_list, equ_list, + check_solution) + def _get_item(self, s, ix_type, name, load=True): """Return the Java object for item *name* of *ix_type*. diff --git a/ixmp/model/gams.py b/ixmp/model/gams.py index 5d198a075..88be16a90 100644 --- a/ixmp/model/gams.py +++ b/ixmp/model/gams.py @@ -48,15 +48,15 @@ class GAMSModel(Model): - “LogOption=4” prints output to stdout (not console) and the log file. + check_solution : bool + If True, raise an exception if the GAMS solver did not reach + optimality. (Only for MESSAGE-scheme Scenarios.) comment : str Comment added to Scenario when importing the solution. - var_list : list of str - Variables to be imported from the *out_file*. equ_list : list of str Equations to be imported from the *out_file*. - check_solution : bool - If True, raise an exception if the GAMS solver did not reach - optimality. (Only for MESSAGE-scheme Scenarios.) + var_list : list of str + Variables to be imported from the *out_file*. """ #: Model name. @@ -117,9 +117,7 @@ def format(key): command = ' '.join(command) # Write model data to file - # include_var_equ=False -> do not include variables/equations in GDX - scenario._jobj.toGDX(str(self.in_file.parent), self.in_file.name, - False) + scenario._backend('write_gdx', self.in_file) # Invoke GAMS check_call(command, shell=os.name == 'nt', cwd=model_file.parent) @@ -128,7 +126,9 @@ def format(key): scenario.clear_cache() # Read model solution - scenario._jobj.readSolutionFromGDX( - str(self.out_file.parent), self.out_file.name, self.comment, - as_str_list(self.var_list), as_str_list(self.equ_list), - self.check_solution) + scenario._backend('read_gdx', self.out_file, + self.check_solution, + self.comment, + as_str_list(self.equ_list), + as_str_list(self.var_list), + ) From 52c1937bbf6c20c200392f5e1798cd65f1948ea8 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 23:23:11 +0200 Subject: [PATCH 35/71] Add s_set_meta to Backend --- ixmp/backend/base.py | 4 ++++ ixmp/backend/jdbc.py | 3 +++ ixmp/core.py | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 779320f65..f8261f34d 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -303,3 +303,7 @@ def s_add_par_values(self, s, name, elements): If the Backend encounters any error adding the parameter values. """ pass + + @abstractmethod + def s_set_meta(self, s, name, value): + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 21945b998..f534ab221 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -442,6 +442,9 @@ def s_add_par_values(self, s, name, elements): # - (value, unit, comment) jPar.addElement(*args) + def s_set_meta(self, s, name, value): + self.jindex[s].setMeta(name, value) + # Helpers; not part of the Backend interface def s_write_gdx(self, s, path): diff --git a/ixmp/core.py b/ixmp/core.py index 55334eb99..69ed1c2e8 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1454,7 +1454,7 @@ def set_meta(self, name, value): value : str or number or bool metadata attribute value """ - self._jobj.setMeta(name, value) + self._backend('set_meta', name, value) # %% auxiliary functions for class Scenario From 82758ae2b17a9280c34a4e6afe07d824ec43ae2e Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 23:24:35 +0200 Subject: [PATCH 36/71] Add s_clear_solution to Backend --- ixmp/backend/base.py | 4 ++++ ixmp/backend/jdbc.py | 6 ++++++ ixmp/core.py | 6 ++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index f8261f34d..305f4bfaf 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -307,3 +307,7 @@ def s_add_par_values(self, s, name, elements): @abstractmethod def s_set_meta(self, s, name, value): pass + + @abstractmethod + def s_clear_solution(self, s, from_year=None): + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index f534ab221..06a34d23f 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -445,6 +445,12 @@ def s_add_par_values(self, s, name, elements): def s_set_meta(self, s, name, value): self.jindex[s].setMeta(name, value) + def s_clear_solution(self, s, from_year=None): + if from_year: + self.jindex[s].removeSolution(from_year) + else: + self.jindex[s].removeSolution() + # Helpers; not part of the Backend interface def s_write_gdx(self, s, path): diff --git a/ixmp/core.py b/ixmp/core.py index 69ed1c2e8..126cf46e4 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1269,10 +1269,8 @@ def remove_solution(self, first_model_year=None): """ if self.has_solution(): self.clear_cache() # reset Python data cache - if check_year(first_model_year, 'first_model_year'): - self._jobj.removeSolution(first_model_year) - else: - self._jobj.removeSolution() + check_year(first_model_year, 'first_model_year') + self._backend('clear_solution', first_model_year) else: raise ValueError('This Scenario does not have a solution!') From 53279ec64ceb6cd8b7397c101194cf7fbaea5af5 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 23:34:33 +0200 Subject: [PATCH 37/71] Add s_get_meta to Backend --- ixmp/backend/base.py | 4 ++++ ixmp/backend/jdbc.py | 9 +++++++++ ixmp/core.py | 10 ++-------- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 305f4bfaf..31b565699 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -304,6 +304,10 @@ def s_add_par_values(self, s, name, elements): """ pass + @abstractmethod + def s_get_meta(self, s): + pass + @abstractmethod def s_set_meta(self, s, name, value): pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 06a34d23f..51a273c00 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -36,6 +36,7 @@ 'at.ac.iiasa.ixmp.Platform', 'java.lang.Double', 'java.lang.Integer', + 'java.math.BigDecimal', 'java.util.HashMap', 'java.util.LinkedHashMap', 'java.util.LinkedList', @@ -442,6 +443,14 @@ def s_add_par_values(self, s, name, elements): # - (value, unit, comment) jPar.addElement(*args) + def s_get_meta(self, s): + def unwrap(v): + """Unwrap metadata numeric value (BigDecimal -> Double)""" + return v.doubleValue() if isinstance(v, java.BigDecimal) else v + + return {entry.getKey(): unwrap(entry.getValue()) + for entry in self.jindex[s].getMeta().entrySet()} + def s_set_meta(self, s, name, value): self.jindex[s].setMeta(name, value) diff --git a/ixmp/core.py b/ixmp/core.py index 126cf46e4..e28a5ae9e 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1433,14 +1433,8 @@ def get_meta(self, name=None): name : str, optional metadata attribute name """ - def unwrap(value): - """Unwrap metadata numeric value (BigDecimal -> Double)""" - if type(value).__name__ == 'java.math.BigDecimal': - return value.doubleValue() - return value - meta = np.array(self._jobj.getMeta().entrySet().toArray()[:]) - meta = {x.getKey(): unwrap(x.getValue()) for x in meta} - return meta if name is None else meta[name] + all_meta = self._backend('get_meta') + return all_meta[name] if name else all_meta def set_meta(self, name, value): """set scenario metadata From 61b977601ab3ed091153c079385a598ad652bc61 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 23:42:41 +0200 Subject: [PATCH 38/71] Add s_delete_item to Backend --- ixmp/backend/base.py | 5 +++++ ixmp/backend/jdbc.py | 10 +++++++--- ixmp/core.py | 4 ++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 31b565699..cf9d20adf 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -230,6 +230,11 @@ def s_init_item(self, s, type, name): """Initialize an item *name* of *type* in Scenario *s* (required).""" pass + @abstractmethod + def s_delete_item(self, s, type, name): + """Remove an item *name* of *type* in Scenario *s* (required).""" + pass + @abstractmethod def s_item_index(self, s, name, sets_or_names): """Return the index sets or names of item *name* (required). diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 51a273c00..71fb4ef93 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -33,6 +33,7 @@ JAVA_CLASSES = [ 'at.ac.iiasa.ixmp.exceptions.IxException', 'at.ac.iiasa.ixmp.objects.Scenario', + 'at.ac.iiasa.ixmp.objects.TimeSeries.TimeSpan', 'at.ac.iiasa.ixmp.Platform', 'java.lang.Double', 'java.lang.Integer', @@ -338,6 +339,10 @@ def s_init_item(self, s, type, name, idx_sets, idx_names): # aren't exposed by Backend, so don't return here func(name, idx_sets, idx_names) + def s_delete_item(self, s, type, name): + """Remove an item *name* of *type* in Scenario *s*.""" + getattr(self.jindex[s], f'remove{type.title()}')() + def s_item_index(self, s, name, sets_or_names): jitem = self._get_item(s, 'item', name, load=False) return list(getattr(jitem, f'getIdx{sets_or_names.title()}')()) @@ -585,7 +590,7 @@ def to_jlist(pylist, idx_names=None): def to_jlist2(arg, convert=None): - """Simple conversion of :class:`list` *arg* to JLinkedList.""" + """Simple conversion of :class:`list` *arg* to java.LinkedList.""" jlist = java.LinkedList() if convert: @@ -605,5 +610,4 @@ def to_jlist2(arg, convert=None): @lru_cache(1) def timespans(): # Mapping for the enums of at.ac.iiasa.ixmp.objects.TimeSeries.TimeSpan - jTimeSpan = JClass('at.ac.iiasa.ixmp.objects.TimeSeries$TimeSpan') - return {t.ordinal(): t.name() for t in jTimeSpan.values()} + return {t.ordinal(): t.name() for t in java.TimeSpan.values()} diff --git a/ixmp/core.py b/ixmp/core.py index e28a5ae9e..c8910faf8 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -915,7 +915,7 @@ def remove_set(self, name, key=None): self.clear_cache(name=name, ix_type='set') if key is None: - self._jobj.removeSet(name) + self._backend('remove_item', 'set', name) else: _remove_ele(self._jobj.getSet(name), key) @@ -1097,7 +1097,7 @@ def remove_par(self, name, key=None): self.clear_cache(name=name, ix_type='par') if key is None: - self._jobj.removePar(name) + self._backend('remove_item', 'par', name) else: _remove_ele(self._jobj.getPar(name), key) From 2d7839d02149d336ace7dd2306d00c459f2990e5 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 24 Oct 2019 23:59:14 +0200 Subject: [PATCH 39/71] Add s_item_delete_elements to Backend --- ixmp/backend/base.py | 4 ++++ ixmp/backend/jdbc.py | 6 +++++- ixmp/core.py | 44 +++++++++++++++++++------------------------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index cf9d20adf..8fedcf43b 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -309,6 +309,10 @@ def s_add_par_values(self, s, name, elements): """ pass + @abstractmethod + def s_item_delete_elements(self, s, type, name, key): + pass + @abstractmethod def s_get_meta(self, s): pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 71fb4ef93..d160a643d 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -340,7 +340,6 @@ def s_init_item(self, s, type, name, idx_sets, idx_names): func(name, idx_sets, idx_names) def s_delete_item(self, s, type, name): - """Remove an item *name* of *type* in Scenario *s*.""" getattr(self.jindex[s], f'remove{type.title()}')() def s_item_index(self, s, name, sets_or_names): @@ -448,6 +447,11 @@ def s_add_par_values(self, s, name, elements): # - (value, unit, comment) jPar.addElement(*args) + def s_item_delete_elements(self, s, type, name, keys): + jitem = self._get_item(s, type, name, load=False) + for key in keys: + jitem.removeElement(key) + def s_get_meta(self, s): def unwrap(v): """Unwrap metadata numeric value (BigDecimal -> Double)""" diff --git a/ixmp/core.py b/ixmp/core.py index c8910faf8..81717d19c 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -744,6 +744,19 @@ def idx_names(self, name): """ return self._backend('item_index', name, 'names') + def _keys(self, name, key_or_keys): + if isinstance(key_or_keys, (list, pd.Series)): + return as_str_list(key_or_keys) + elif isinstance(key_or_keys, (pd.DataFrame, dict)): + if isinstance(key_or_keys, dict): + key_or_keys = pd.DataFrame.from_dict( + key_or_keys, orient='columns', dtype=None) + idx_names = self.idx_names(name) + return [as_str_list(row, idx_names) + for _, row in key_or_keys.iterrows()] + else: + return [str(key_or_keys)] + def cat_list(self, name): raise DeprecationWarning('function was migrated to `message_ix` class') @@ -915,9 +928,10 @@ def remove_set(self, name, key=None): self.clear_cache(name=name, ix_type='set') if key is None: - self._backend('remove_item', 'set', name) + self._backend('delete_item', 'set', name) else: - _remove_ele(self._jobj.getSet(name), key) + self._backend('item_delete_elements', 'set', name, + self._keys(name, key)) def par_list(self): """List all defined parameters.""" @@ -1097,9 +1111,10 @@ def remove_par(self, name, key=None): self.clear_cache(name=name, ix_type='par') if key is None: - self._backend('remove_item', 'par', name) + self._backend('delete_item', 'par', name) else: - _remove_ele(self._jobj.getPar(name), key) + self._backend('item_delete_elements', 'par', name, + self._keys(name, key)) def var_list(self): """List all defined variables.""" @@ -1491,24 +1506,3 @@ def to_iamc_template(df): raise ValueError("missing required columns `{}`!".format(missing)) return df - - -def _remove_ele(item, key): - """auxiliary """ - if item.getDim() > 0: - if isinstance(key, list) or isinstance(key, pd.Series): - item.removeElement(as_str_list(key)) - elif isinstance(key, pd.DataFrame) or isinstance(key, dict): - if isinstance(key, dict): - key = pd.DataFrame.from_dict(key, orient='columns', dtype=None) - idx_names = list(item.getIdxNames()) - for i in key.index: - item.removeElement(as_str_list(key.loc[i], idx_names)) - else: - item.removeElement(str(key)) - - else: - if isinstance(key, list) or isinstance(key, pd.Series): - item.removeElement(as_str_list(key)) - else: - item.removeElement(str(key)) From f83e0f15498b1ed4cef766f4b74d2be9581d6b0e Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 00:01:19 +0200 Subject: [PATCH 40/71] Remove shims Scenario._jobj and ._item --- ixmp/core.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/ixmp/core.py b/ixmp/core.py index 81717d19c..20aca78b3 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -656,14 +656,6 @@ def __init__(self, mp, model, scenario, version=None, scheme=None, self._cache = cache self._pycache = {} - @property - def _jobj(self): - """Shim to allow existing code that references ._jobj to work.""" - # TODO address all such warnings, then remove - loc = inspect.stack()[1].function - warn(f'Accessing Scenario._jobj in {loc}') - return self.platform._backend.jindex[self] - def _backend(self, method, *args, **kwargs): """Convenience for calling *method* on the backend.""" try: @@ -672,13 +664,6 @@ def _backend(self, method, *args, **kwargs): func = getattr(self.platform._backend, f'ts_{method}') return func(self, *args, **kwargs) - def _item(self, ix_type, name, load=True): - """Shim to allow existing code that references ._item to work.""" - # TODO address all such warnings, then remove - loc = inspect.stack()[1].function - warn(f'Calling {self.__class__.__name__}._item() in {loc}') - return self.platform._backend._get_item(self, ix_type, name) - def load_scenario_data(self): """Load all Scenario data into memory. From 896b3b82c4f33689a78beb1f3c3908f3727bd10b Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 00:02:24 +0200 Subject: [PATCH 41/71] Lint core.py --- ixmp/core.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ixmp/core.py b/ixmp/core.py index 20aca78b3..5405ec26e 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,10 +1,8 @@ # coding=utf-8 -import inspect from itertools import repeat, zip_longest import logging from warnings import warn -import numpy as np import pandas as pd from .backend import BACKENDS, FIELDS From eadde3d25b877391d776a6ea51fd913ef37e2ab5 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 00:05:24 +0200 Subject: [PATCH 42/71] Remove Scenario.years_active (should be in message_ix) --- ixmp/core.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/ixmp/core.py b/ixmp/core.py index 5405ec26e..4c863e92e 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1407,22 +1407,6 @@ def clear_cache(self, name=None, ix_type=None): if key is not None: self._pycache.pop(key) - def years_active(self, node, tec, yr_vtg): - """return a list of years in which a technology of certain vintage - at a specific node can be active - - Parameters - ---------- - node : str - node name - tec : str - name of the technology - yr_vtg : str - vintage year - """ - # TODO this is specific to message_ix.Scenario AND is untested; remove - return list(self._jobj.getTecActYrs(node, tec, str(yr_vtg))) - def get_meta(self, name=None): """get scenario metadata From 6f087360bdb6d5c4853656132740db29768582d8 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 01:14:55 +0200 Subject: [PATCH 43/71] Changes caught by message_ix, yet not ixmp, tests 1 --- ixmp/__init__.py | 1 + ixmp/backend/jdbc.py | 2 +- ixmp/core.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ixmp/__init__.py b/ixmp/__init__.py index c9dd5b00f..c8cf2d339 100644 --- a/ixmp/__init__.py +++ b/ixmp/__init__.py @@ -3,6 +3,7 @@ del get_versions from ixmp.core import ( # noqa: E402,F401 + IAMC_IDX, Platform, TimeSeries, Scenario, diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index d160a643d..595720832 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -450,7 +450,7 @@ def s_add_par_values(self, s, name, elements): def s_item_delete_elements(self, s, type, name, keys): jitem = self._get_item(s, type, name, load=False) for key in keys: - jitem.removeElement(key) + jitem.removeElement(to_jlist2(key)) def s_get_meta(self, s): def unwrap(v): diff --git a/ixmp/core.py b/ixmp/core.py index 4c863e92e..e09c6455e 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -868,7 +868,7 @@ def add_set(self, name, key, comment=None): keys = [as_str_list(key)] elif isinstance(key[0], list): # List of lists of key values; convert to list of list of str - keys = map(as_str_list, key) + keys = list(map(as_str_list, key)) elif isinstance(key, str) and len(idx_names) == 1: # Bare key given for a 1D set; wrap for convenience keys = [[key]] From 1230170fa06608b0a59c5133e9aff0db7b284ab1 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 01:15:14 +0200 Subject: [PATCH 44/71] Add ms_cat_set_elements to Backend for message_ix --- ixmp/backend/jdbc.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 595720832..eba53487e 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -469,6 +469,11 @@ def s_clear_solution(self, s, from_year=None): else: self.jindex[s].removeSolution() + # MsgScenario methods + + def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): + self.jindex[ms].addCatEle(name, cat, to_jlist2(keys), is_unique) + # Helpers; not part of the Backend interface def s_write_gdx(self, s, path): From df146b2e12f41ca3b8273f5baa2486d29e7cae2e Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 01:31:26 +0200 Subject: [PATCH 45/71] Add ms_cat_list, _cat_get_elements, _year_first_model, _years_active --- ixmp/backend/jdbc.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index eba53487e..857bd132b 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -471,9 +471,21 @@ def s_clear_solution(self, s, from_year=None): # MsgScenario methods + def ms_cat_list(self, ms, name): + return to_pylist(self.jindex[ms].getTypeList(name)) + + def ms_cat_get_elements(self, ms, name, cat): + return to_pylist(self.jindex[ms].getCatEle(name, cat)) + def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): self.jindex[ms].addCatEle(name, cat, to_jlist2(keys), is_unique) + def ms_year_first_model(self, ms): + return self.jindex[ms].getFirstModelYear() + + def ms_years_active(self, ms, node, tec, year_vintage): + return list(self.jindex[ms].getTecActYrs(node, tec, year_vintage)) + # Helpers; not part of the Backend interface def s_write_gdx(self, s, path): From d03f2a58b6a7bcfd03a8d15ecc4b50becbbb1a0b Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 12:07:15 +0200 Subject: [PATCH 46/71] Expand documentation and docstrings --- doc/source/api-backend.rst | 122 ++++++++++++++++------- doc/source/api-model.rst | 36 +++++++ doc/source/api-python.rst | 184 +++++++++++++++++++++++++++++++++- doc/source/api.rst | 1 + ixmp/backend/base.py | 77 ++++++++++++++- ixmp/backend/jdbc.py | 76 +++++++++------ ixmp/config.py | 22 ----- ixmp/core.py | 195 +++++++++++-------------------------- ixmp/model/__init__.py | 10 +- ixmp/model/base.py | 20 +++- ixmp/model/gams.py | 84 +++++++++------- 11 files changed, 552 insertions(+), 275 deletions(-) create mode 100644 doc/source/api-model.rst diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index 3c24bfddd..b672b813b 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -1,73 +1,123 @@ .. currentmodule:: ixmp.backend -Storage back ends (:mod:`ixmp.backend` package) -=============================================== +Storage back ends (:mod:`ixmp.backend`) +======================================= -By default, the |ixmp| is installed with :class:`ixmp.backend.jdbc.JDBCBackend`, which can store data many types of relational database management systems (RDBMS) that have Java DataBase Connector (JDBC) interfaces—hence its name. -These include: - -- ``dbtype='HSQLDB'``: databases in local files. -- Remote databases. This is accomplished by creating a :class:`ixmp.Platform` with the ``dbprops`` argument pointing a file that specifies JDBC information. For instance:: - - jdbc.driver = oracle.jdbc.driver.OracleDriver - jdbc.url = jdbc:oracle:thin:@database-server.example.com:1234:SCHEMA - jdbc.user = USER - jdbc.pwd = PASSWORD +By default, the |ixmp| is installed with :class:`ixmp.backend.jdbc.JDBCBackend`, which can store data in many types of relational database management systems (RDBMS) that have Java DataBase Connector (JDBC) interfaces—hence its name. However, |ixmp| is extensible to support other methods of storing data: in non-JDBC RDBMS, non-relational databases, local files, memory, or other ways. Developers wishing to add such capabilities may subclass :class:`ixmp.backend.base.Backend` and implement its methods. -Implementing custom backends ----------------------------- - -In the following, the words MUST, MAY, etc. have specific meanings as described in RFC ____. -- :class:`ixmp.Platform` implements a *user-friendly* API for scientific programming. - This means its methods can take many types of arguments, check, and transform them in a way that provides modeler-users with an easy, intuitive workflow. -- In contrast, :class:`Backend` has a very simple API that accepts and returns - arguments in basic Python data types and structures. - Custom backends need not to perform argument checking: merely store and retrieve data reliably. -- Some methods below are decorated as :meth:`abc.abstractmethod`; this means - they MUST be overridden by a subclass of Backend. -- Others that are not so decorated and have “(optional)” in their signature are not required. The behaviour in base.Backend—often, nothing—is an acceptable default behaviour. - Subclasses MAY extend or replace this behaviour as desired, so long as the methods still perform the actions described in the description. +Provided backends +----------------- .. automodule:: ixmp.backend :members: BACKENDS +.. currentmodule:: ixmp.backend.jdbc + .. autoclass:: ixmp.backend.jdbc.JDBCBackend + :members: s_write_gdx, s_read_gdx + + JDBCBackend supports: + + - ``dbtype='HSQLDB'``: HyperSQL databases in local files. + - Remote databases. This is accomplished by creating a :class:`ixmp.Platform` with the ``dbprops`` argument pointing a file that specifies JDBC information. For instance:: + + jdbc.driver = oracle.jdbc.driver.OracleDriver + jdbc.url = jdbc:oracle:thin:@database-server.example.com:1234:SCHEMA + jdbc.user = USER + jdbc.pwd = PASSWORD + + It has the following methods that are not part of the overall :class:`Backend` API: + + .. autosummary:: + s_write_gdx + s_read_gdx + +.. automethod:: ixmp.backend.jdbc.start_jvm + +Backend API +----------- + +- :class:`ixmp.Platform` implements a *user-friendly* API for scientific programming. + This means its methods can take many types of arguments, check, and transform them—in a way that provides modeler-users with easy, intuitive workflows. +- In contrast, :class:`Backend` has a *very simple* API that accepts arguments and returns values in basic Python data types and structures. +- As a result: + + - :class:`Platform ` code does is not affected by where and how data is stored; it merely handles user arguments and then makes, usually, a single :class:`Backend` call. + - :class:`Backend` code does not need to perform argument checking; merely store and retrieve data reliably. + +.. currentmodule:: ixmp.backend.base .. autoclass:: ixmp.backend.base.Backend - :members: + + In the following, the words REQUIRED, OPTIONAL, etc. have specific meanings as described in `IETF RFC 2119 `_. + + Backend is an **abstract** class; this means it MUST be subclassed. + Most of its methods are decorated with :meth:`abc.abstractmethod`; this means they are REQUIRED and MUST be overridden by subclasses. + + Others, marked below with “(OPTIONAL)”, are not so decorated. + For these methods, the behaviour in the base Backend—often, nothing—is an acceptable default behaviour. + Subclasses MAY extend or replace this behaviour as desired, so long as the methods still perform the actions described in the description. Methods related to :class:`ixmp.Platform`: .. autosummary:: - set_log_level - open_db close_db + get_auth get_nodes get_scenarios get_units + open_db + set_log_level + set_nodes + set_unit Methods related to :class:`ixmp.TimeSeries`: + - ‘Geodata’ is otherwise identical to regular timeseries data, except value are :class:`str` rather than :class:`float`. + .. autosummary:: - ts_init ts_check_out ts_commit - ts_set - ts_get ts_delete + ts_delete_geo + ts_discard_changes + ts_get + ts_get_geo + ts_init + ts_is_default + ts_last_update + ts_preload + ts_run_id + ts_set + ts_set_as_default + ts_set_geo Methods related to :class:`ixmp.Scenario`: .. autosummary:: - s_init + s_add_par_values + s_add_set_elements + s_clone + s_delete_item + s_get_meta s_has_solution - s_list_items + s_init s_init_item - s_item_index + s_item_delete_elements s_item_elements - s_add_set_elements - s_add_par_values + s_item_index + s_list_items + s_set_meta + + Methods related to :class:`message_ix.Scenario`: + + .. autosummary:: + ms_cat_get_elements + ms_cat_list + ms_cat_set_elements + ms_year_first_model + ms_years_active diff --git a/doc/source/api-model.rst b/doc/source/api-model.rst new file mode 100644 index 000000000..45da84a1d --- /dev/null +++ b/doc/source/api-model.rst @@ -0,0 +1,36 @@ +.. currentmodule:: ixmp.model + +Model formulations (:mod:`ixmp.model`) +====================================== + +By default, the |ixmp| is installed with :class:`ixmp.model.gams.GAMSModel`, which performs calculations by executing code stored in GAMS files. + +However, |ixmp| is extensible to support other methods of performing calculations or optimization. +Developers wishing to add such capabilities may subclass :class:`ixmp.model.base.Model` and implement its methods. + + +Provided models +--------------- + +.. automodule:: ixmp.model + :members: get_model, MODELS + +.. autoclass:: ixmp.model.gams.GAMSModel + :members: + + +Model API +--------- + +.. autoclass:: ixmp.model.base.Model + :members: name, __init__, run + + In the following, the words REQUIRED, OPTIONAL, etc. have specific meanings as described in `IETF RFC 2119 `_. + + 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__ + run diff --git a/doc/source/api-python.rst b/doc/source/api-python.rst index 21b96401f..491463bef 100644 --- a/doc/source/api-python.rst +++ b/doc/source/api-python.rst @@ -3,8 +3,188 @@ Python (:mod:`ixmp` package) ============================ -.. automodule:: ixmp - :members: Platform, TimeSeries, Scenario +The |ixmp| application progamming interface (API) is organized around three classes: + +.. autosummary:: + + Platform + TimeSeries + Scenario + +Platform +-------- + +.. autoclass:: Platform + :members: + + Platforms have the following methods: + + .. autosummary:: + add_region + add_region_synonym + add_unit + check_access + regions + scenario_list + set_log_level + + +TimeSeries +---------- + +.. autoclass:: TimeSeries + :members: + + A TimeSeries is uniquely identified on its :class:`Platform` by three + values: + + 1. `model`: the name of a model used to perform calculations between input + and output data. + + - In TimeSeries storing non-model data, arbitrary strings can be used. + - In a :class:`Scenario`, the `model` is a reference to a GAMS program + registered to the :class:`Platform` that can be solved with + :meth:`Scenario.solve`. See :attr:`ixmp.model.MODELS`. + + 2. `scenario`: the name of a specific, coherent description of the real- + world system being modeled. Any `model` may be used to represent mutiple + alternate, or 'counter-factual', `scenarios`. + 3. `version`: an integer identifying a specific iteration of a + (`model`, `scenario`). A new `version` is created by: + + - Instantiating a new TimeSeries with the same `model` and `scenario` as + an existing TimeSeries. + - Calling :meth:`Scenario.clone`. + + Optionally, one `version` may be set as a **default version**. See + :meth:`set_as_default`. + + TimeSeries objects have the following methods: + + .. autosummary:: + add_geodata + add_timeseries + check_out + commit + discard_changes + get_geodata + is_default + last_update + preload_timeseries + remove_geodata + remove_timeseries + run_id + set_as_default + timeseries + + +Scenario +-------- + +.. autoclass:: Scenario + :show-inheritance: + :members: + + A Scenario is a :class:`TimeSeries` associated with a particular model that + can be run on the current :class:`Platform` by calling :meth:`solve`. The + Scenario also stores the output, or 'solution' of a model run; this + includes the 'level' and 'marginal' values of GAMS equations and variables. + + Data in a Scenario are closely related to different types in the GAMS data + model: + + - A **set** is a named collection of labels. See :meth:`init_set`, + :meth:`add_set`, and :meth:`set`. There are two types of sets: + + 1. Sets that are lists of labels. + 2. Sets that are 'indexed' by one or more other set(s). For this type of + set, each member is an ordered tuple of the labels in the index sets. + + - A **scalar** is a named, single, numerical value. See + :meth:`init_scalar`, :meth:`change_scalar`, and :meth:`scalar`. + + - **Parameters**, **variables**, and **equations** are multi-dimensional + arrays of values that are indexed by one or more sets (i.e. with + dimension 1 or greater). The Scenario methods for handling these types + are very similar; they mainly differ in how they are used within GAMS + models registered with ixmp: + + - **Parameters** are generic data that can be defined before a model run. + They may be altered by the model solution. See :meth:`init_par`, + :meth:`remove_par`, :meth:`par_list`, :meth:`add_par`, and :meth:`par`. + - **Variables** are calculated during or after a model run by GAMS code, + so they cannot be modified by a Scenario. See :meth:`init_var`, + :meth:`var_list`, and :meth:`var`. + - **Equations** describe fundamental relationships between other types + (parameters, variables, and scalars) in a model. They are defined in + GAMS code, so cannot be modified by a Scenario. See :meth:`init_equ`, + :meth:`equ_list`, and :meth:`equ`. + + .. autosummary:: + add_par + add_set + change_scalar + clear_cache + clone + equ + equ_list + get_meta + has_equ + has_par + has_set + has_solution + has_var + idx_names + idx_sets + init_equ + init_par + init_scalar + init_set + init_var + load_scenario_data + par + par_list + remove_par + remove_set + remove_solution + scalar + set + set_list + set_meta + solve + var + var_list + +Configuration +------------- + +.. currentmodule:: ixmp.config + +.. autoclass:: Config + + When imported, :mod:`ixmp` reads configuration from the first file named + ``config.json`` found in one of the following directories: + + 1. The directory given by the environment variable ``IXMP_DATA``, if + defined, + 2. ``${XDG_DATA_HOME}/ixmp``, if the environment variable is defined, + 3. ``$HOME/.local/share/ixmp``, or + 4. ``$HOME/.local/ixmp`` (deprecated; retained for compatibility with ixmp + <= 1.1). + + The file may define either or both of the following configuration keys, in + JSON format: + + - `DB_CONFIG_PATH`: location for database properties files. A + :class:`ixmp.Platform` instantiated with a relative path name for the + `dbprops` argument will locate the file first in the current working + directory, then in `DB_CONFIG_PATH`, then in the four directories above. + - `DEFAULT_DBPROPS_FILE`: path to a default database properties file. + A :class:`ixmp.Platform` instantiated with no arguments will use this + file. + - `DEFAULT_LOCAL_DB_PATH`: path to a directory where a local directory + should be created. A :class:`ixmp.Platform` instantiated with + `dbtype='HSQLDB'` will create or reuse a database in this path. Testing utilities diff --git a/doc/source/api.rst b/doc/source/api.rst index 7e1086e3f..e3c000184 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -10,6 +10,7 @@ On separate pages: api-python api-backend + api-model reporting On this page: diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 8fedcf43b..c2e7726a2 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -27,7 +27,7 @@ def set_log_level(self, level): pass def open_db(self): - """(Re-)open a database connection (optional). + """(Re-)open a database connection (OPTIONAL). A backend MAY connect to a database server. This method opens the database connection if it is closed. @@ -35,14 +35,14 @@ def open_db(self): pass def close_db(self): - """Close a database connection (optional). + """Close a database connection (OPTIONAL). Close a database connection if it is open. """ pass def get_auth(self, user, models, kind): - """Return user authorization for models (optional). + """Return user authorization for models (OPTIONAL). If the Backend implements access control… """ @@ -72,6 +72,11 @@ def get_nodes(self): def get_scenarios(self, default, model, scenario): pass + @abstractmethod + def set_nodes(self): + # TODO document + pass + @abstractmethod def set_unit(self, name, comment): pass @@ -86,6 +91,8 @@ def get_units(self): """ pass + # Methods for ixmp.TimeSeries + @abstractmethod def ts_init(self, ts, annotation=None): """Initialize the TimeSeries *ts* (required). @@ -202,6 +209,43 @@ def ts_delete_geo(self, ts, region, variable, time, years, unit): """Remove time-series 'geodata'.""" pass + @abstractmethod + def ts_discard_changes(self, ts): + # TODO document + pass + + @abstractmethod + def ts_set_as_default(self, ts): + # TODO document + pass + + @abstractmethod + def ts_is_default(self, ts): + # TODO document + pass + + @abstractmethod + def ts_last_update(self, ts): + # TODO document + pass + + @abstractmethod + def ts_run_id(self, ts): + # TODO document + pass + + @abstractmethod + def ts_preload(self, ts): + # TODO document + pass + + # Methods for ixmp.Scenario + + @abstractmethod + def s_clone(): + # TODO + pass + @abstractmethod def s_init(self, s, annotation=None): """Initialize the Scenario *s* (required). @@ -324,3 +368,30 @@ def s_set_meta(self, s, name, value): @abstractmethod def s_clear_solution(self, s, from_year=None): pass + + # Methods for message_ix.Scenario + + @abstractmethod + def ms_cat_list(self, ms, name): + """Return list of categories.""" + pass + + @abstractmethod + def ms_cat_get_elements(self, ms, name, cat): + """Get elements of a category mapping.""" + pass + + @abstractmethod + def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): + """Add elements to category mapping.""" + pass + + @abstractmethod + def ms_year_first_model(self, ms): + """Return the first model year.""" + pass + + @abstractmethod + def ms_years_active(self, ms, node, tec, year_vintage): + """Return a list of years in which *tec* is active.""" + pass diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 857bd132b..b55cb714c 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -47,15 +47,36 @@ class JDBCBackend(Backend): """Backend using JPype and JDBC to connect to Oracle and HSQLDB instances. - Much of the code of this backend is implemented in Java code in the - iiasa/ixmp_source Github repository. - - Among other things, this backend: + Among other abstractions, this backend: - Catches Java exceptions such as ixmp.exceptions.IxException, and re-raises them as appropriate Python exceptions. + Parameters + ---------- + dbtype : 'HSQLDB', optional + Database type to use. If :obj:`None`, a remote database is accessed. If + 'HSQLDB', a local database is created and used at the path given by + `dbprops`. + + dbprops : path-like, optional + If `dbtype` is :obj:`None`, the name of a *database properties file* + (default: ``default.properties``) in the properties file directory + (see :class:`Config `) or a path to a properties + file. + + If `dbtype` is 'HSQLDB'`, the path of a local database, + (default: ``$HOME/.local/ixmp/localdb/default``) or name of a + database file in the local database directory (default: + ``$HOME/.local/ixmp/localdb/``). + + jvmargs : str, optional + Java Virtual Machine arguments. + See :meth:`ixmp.backend.jdbc.start_jvm`. """ + # NB Much of the code of this backend is implemented in Java code in the + # (private) iiasa/ixmp_source Github repository. + #: Reference to the at.ac.iiasa.ixmp.Platform Java object jobj = None @@ -125,13 +146,6 @@ def get_nodes(self): yield (n, None, p, h) yield from [(s, n, p, h) for s in (r.getSynonyms() or [])] - def set_unit(self, name, comment): - self.jobj.addUnitToDB(name, comment) - - def get_units(self): - """Return all units described in the database.""" - return to_pylist(self.jobj.getUnitList()) - def get_scenarios(self, default, model, scenario): # List> scenarios = self.jobj.getScenarioList(default, model, scenario) @@ -142,10 +156,15 @@ def get_scenarios(self, default, model, scenario): data.append(int(s[field]) if field == 'version' else s[field]) yield data + def set_unit(self, name, comment): + self.jobj.addUnitToDB(name, comment) + + def get_units(self): + return to_pylist(self.jobj.getUnitList()) + # Timeseries methods def ts_init(self, ts, annotation=None): - """Initialize the ixmp.TimeSeries *ts*.""" if ts.version == 'new': # Create a new TimeSeries jobj = self.jobj.newTimeSeries(ts.model, ts.scenario, annotation) @@ -171,32 +190,24 @@ def ts_commit(self, ts, comment): ts.version = self.jindex[ts].getVersion() def ts_discard_changes(self, ts): - """Discard all changes and reload from the database.""" self.jindex[ts].discardChanges() def ts_set_as_default(self, ts): - """Set the current :attr:`version` as the default.""" self.jindex[ts].setAsDefaultVersion() def ts_is_default(self, ts): - """Return :obj:`True` if the :attr:`version` is the default version.""" return bool(self.jindex[ts].isDefault()) def ts_last_update(self, ts): - """get the timestamp of the last update/edit of this TimeSeries""" return self.jindex[ts].getLastUpdateTimestamp().toString() def ts_run_id(self, ts): - """get the run id of this TimeSeries""" return self.jindex[ts].getRunId() def ts_preload(self, ts): - """Preload timeseries data to in-memory cache. Useful for bulk updates. - """ self.jindex[ts].preloadAllTimeseries() def ts_get(self, ts, region, variable, unit, year): - """Retrieve time-series data.""" # Convert the selectors to Java lists r = to_jlist2(region) v = to_jlist2(variable) @@ -216,7 +227,6 @@ def ts_get(self, ts, region, variable, unit, year): for f in FIELDS['ts_get']) def ts_get_geo(self, ts): - """Retrieve time-series 'geodata'.""" # NB the return type of getGeoData() requires more processing than # getTimeseries. It also accepts no selectors. @@ -259,7 +269,6 @@ def ts_get_geo(self, ts): yield tuple(cm[f] for f in FIELDS['ts_get_geo']) def ts_set(self, ts, region, variable, data, unit, meta): - """Store time-series data.""" # Convert *data* to a Java data structure jdata = java.LinkedHashMap() for k, v in data.items(): @@ -270,24 +279,20 @@ def ts_set(self, ts, region, variable, data, unit, meta): meta) def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): - """Store time-series 'geodata'.""" self.jindex[ts].addGeoData(region, variable, time, java.Integer(year), value, unit, meta) def ts_delete(self, ts, region, variable, years, unit): - """Remove time-series data.""" years = to_jlist2(years, java.Integer) self.jindex[ts].removeTimeseries(region, variable, None, years, unit) def ts_delete_geo(self, ts, region, variable, time, years, unit): - """Remove time-series 'geodata'.""" years = to_jlist2(years, java.Integer) self.jindex[ts].removeGeoData(region, variable, time, years, unit) # Scenario methods def s_init(self, s, scheme=None, annotation=None): - """Initialize the ixmp.Scenario *s*.""" if s.version == 'new': jobj = self.jobj.newScenario(s.model, s.scenario, scheme, annotation) @@ -408,7 +413,6 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, return data def s_add_set_elements(self, s, name, elements): - """Add elements to set *name* in Scenario *s*.""" # Retrieve the Java Set and its number of dimensions jSet = self._get_item(s, 'set', name) dim = jSet.getDim() @@ -430,7 +434,6 @@ def s_add_set_elements(self, s, name, elements): raise RuntimeError('Unhandled Java exception') from e def s_add_par_values(self, s, name, elements): - """Add values to parameter *name* in Scenario *s*.""" jPar = self._get_item(s, 'par', name) for key, value, unit, comment in elements: @@ -464,7 +467,12 @@ def s_set_meta(self, s, name, value): self.jindex[s].setMeta(name, value) def s_clear_solution(self, s, from_year=None): + from ixmp.core import Scenario + if from_year: + if type(s) is not Scenario: + raise TypeError('s_clear_solution(from_year=...) only valid ' + 'for ixmp.Scenario; not subclasses') self.jindex[s].removeSolution(from_year) else: self.jindex[s].removeSolution() @@ -537,12 +545,20 @@ def _get_item(self, s, ix_type, name, load=True): def start_jvm(jvmargs=None): - """Start the Java Virtual Machine via JPype. + """Start the Java Virtual Machine via :mod:`JPype`. Parameters ---------- jvmargs : str or list of str, optional - Additional arguments to pass to :meth:`jpype.startJVM`. + Additional arguments for launching the JVM, passed to + :meth:`jpype.startJVM`. + + For instance, to set the maximum heap space to 4 GiB, give + ``jvmargs=['-Xmx4G']``.See the `JVM documentation`_ for a list of + options. + + .. _`JVM documentation`: https://docs.oracle.com/javase/7/docs + /technotes/tools/windows/java.html) """ # TODO change the jvmargs default to [] instead of None if jpype.isJVMStarted(): diff --git a/ixmp/config.py b/ixmp/config.py index 4ff93560d..481c501d2 100644 --- a/ixmp/config.py +++ b/ixmp/config.py @@ -14,32 +14,10 @@ class Config: """Configuration for ixmp. - When imported, :mod:`ixmp` reads a configuration file `config.json` in the - first of the following directories: - - 1. `IXMP_DATA`, if defined. - 2. `${XDG_DATA_HOME}/ixmp`, if defined. - 3. `$HOME/.local/share/ixmp`. - 4. `$HOME/.local/ixmp` (used by ixmp <= 1.1). - - The file may define either or both of the following configuration keys, in - JSON format: - - - `DB_CONFIG_PATH`: location for database properties files. A - :class:`ixmp.Platform` instantiated with a relative path name for the - `dbprops` argument will locate the file first in the current working - directory, then in `DB_CONFIG_PATH`, then in the four directories above. - - `DEFAULT_DBPROPS_FILE`: path to a default database properties file. A - :class:`ixmp.Platform` instantiated with no arguments will use this file. - - `DEFAULT_LOCAL_DB_PATH`: path to a directory where a local directory - should be created. A :class:`ixmp.Platform` instantiated with - `dbtype='HSQLDB'` will create or reuse a database in this path. - Parameters ---------- read : bool Read `config.json` on startup. - """ # User configuration keys _keys = [ diff --git a/ixmp/core.py b/ixmp/core.py index e09c6455e..112ddbebe 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -20,53 +20,51 @@ class Platform: - """Database-backed instance of the ixmp. + """Instance of the modeling platform. - Each Platform connects three components: + A Platform connects two key components: - 1. A **database** for storing model inputs and outputs. This may either be - a local file (``dbtype='HSQLDB'``) or a database server accessed via a - network connection. In the latter case, connection information is read - from a `properties file`. - 2. A Java Virtual Machine (**JVM**) to run core ixmp logic and access the - database. - 3. One or more **model(s)**, implemented in GAMS or another language or - framework. + 1. A **back end** for storing data such as model inputs and outputs. + 2. One or more **model(s)**; codes in Python or other languages or + frameworks that run, via :meth:`Scenario.solve`, on the data stored in + the Platform. - The constructor parameters control these components. :class:`TimeSeries` - and :class:`Scenario` objects are specific to one Platform; to move data - between platforms, see :meth:`Scenario.clone`. + The Platform parameters control these components. :class:`TimeSeries` and + :class:`Scenario` objects tied to a single Platform; to move data between + platforms, see :meth:`Scenario.clone`. Parameters ---------- backend : 'jdbc' - Storage backend type. Currently 'jdbc' is the only available backend. - backend_kwargs - Keyword arguments to configure the backend; see below. + Storage backend type. 'jdbc' corresponds to the built-in + :class:`JDBCBackend `; see + :obj:`ixmp.backend.BACKENDS`. + backend_args + Keyword arguments to specific to the `backend`. + The “Other Parameters” shown below are specific to + :class:`JDBCBackend `. Other parameters ---------------- - dbprops : path-like, optional - If `dbtype` is :obj:`None`, the name of a database properties file - (default: 'default.properties') in the properties file directory - (default: ???) or the path of a properties file. - - If `dbtype == 'HSQLDB'`, the path of a local database, - (default: "$HOME/.local/ixmp/localdb/default") or name of a - database file in the local database directory (default: - "$HOME/.local/ixmp/localdb/"). - dbtype : 'HSQLDB', optional - Database type to use. If `None`, a remote database is accessed. If + Database type to use. If :obj:`None`, a remote database is accessed. If 'HSQLDB', a local database is created and used at the path given by `dbprops`. - jvmargs : str, optional - Options for launching the Java Virtual Machine, e.g., the maximum heap - space: "-Xmx4G". See the `JVM documentation`_ for a list of options. + dbprops : path-like, optional + If `dbtype` is :obj:`None`, the name of a *database properties file* + (default: ``default.properties``) in the properties file directory + (see :class:`Config `) or a path to a properties + file. - .. _`JVM documentation`: https://docs.oracle.com/javase/7/docs - /technotes/tools/windows/java.html) + If `dbtype` is 'HSQLDB'`, the path of a local database, + (default: ``$HOME/.local/ixmp/localdb/default``) or name of a + database file in the local database directory (default: + ``$HOME/.local/ixmp/localdb/``). + + jvmargs : str, optional + Java Virtual Machine arguments. + See :meth:`ixmp.backend.jdbc.start_jvm`. """ # List of method names which are handled directly by the backend @@ -108,7 +106,7 @@ def set_log_level(self, level): self._backend.set_log_level(level) def scenario_list(self, default=True, model=None, scen=None): - """Return information on TimeSeries and Scenarios in the database. + """Return information about TimeSeries and Scenarios on the Platform. Parameters ---------- @@ -274,35 +272,10 @@ def _logger_region_exists(_regions, r): class TimeSeries: - """Generic collection of data in time series format. + """Collection of data in time series format. TimeSeries is the parent/super-class of :class:`Scenario`. - A TimeSeries is uniquely identified on its :class:`Platform` by three - values: - - 1. `model`: the name of a model used to perform calculations between input - and output data. - - - In TimeSeries storing non-model data, arbitrary strings can be used. - - In a :class:`Scenario`, the `model` is a reference to a GAMS program - registered to the :class:`Platform` that can be solved with - :meth:`Scenario.solve`. See - :meth:`ixmp.model_settings.register_model`. - - 2. `scenario`: the name of a specific, coherent description of the real- - world system being modeled. Any `model` may be used to represent mutiple - alternate, or 'counter-factual', `scenarios`. - 3. `version`: an integer identifying a specific iteration of a - (`model`, `scenario`). A new `version` is created by: - - - Instantiating a new TimeSeries with the same `model` and `scenario` as - an existing TimeSeries. - - Calling :meth:`Scenario.clone`. - - Optionally, one `version` may be set as a **default version**. See - :meth:`set_as_default`. - Parameters ---------- mp : :class:`Platform` @@ -568,62 +541,18 @@ def get_geodata(self): # %% class Scenario class Scenario(TimeSeries): - """Collection of model-related input and output data. - - A Scenario is a :class:`TimeSeries` associated with a particular model that - can be run on the current :class:`Platform` by calling :meth:`solve`. The - Scenario also stores the output, or 'solution' of a model run; this - includes the 'level' and 'marginal' values of GAMS equations and variables. - - Data in a Scenario are closely related to different types in the GAMS data - model: - - - A **set** is a named collection of labels. See :meth:`init_set`, - :meth:`add_set`, and :meth:`set`. There are two types of sets: - - 1. Sets that are lists of labels. - 2. Sets that are 'indexed' by one or more other set(s). For this type of - set, each member is an ordered tuple of the labels in the index sets. - - - A **scalar** is a named, single, numerical value. See - :meth:`init_scalar`, :meth:`change_scalar`, and :meth:`scalar`. - - - **Parameters**, **variables**, and **equations** are multi-dimensional - arrays of values that are indexed by one or more sets (i.e. with - dimension 1 or greater). The Scenario methods for handling these types - are very similar; they mainly differ in how they are used within GAMS - models registered with ixmp: - - - **Parameters** are generic data that can be defined before a model run. - They may be altered by the model solution. See :meth:`init_par`, - :meth:`remove_par`, :meth:`par_list`, :meth:`add_par`, and :meth:`par`. - - **Variables** are calculated during or after a model run by GAMS code, - so they cannot be modified by a Scenario. See :meth:`init_var`, - :meth:`var_list`, and :meth:`var`. - - **Equations** describe fundamental relationships between other types - (parameters, variables, and scalars) in a model. They are defined in - GAMS code, so cannot be modified by a Scenario. See :meth:`init_equ`, - :meth:`equ_list`, and :meth:`equ`. + """Collection of model-related data. + + See :class:`TimeSeries` for the meaning of parameters `mp`, `model`, + `scenario`, `version`, and `annotation`. Parameters ---------- - mp : :class:`Platform` - ixmp instance in which to store data. - model : str - Model name; must be a registered model. - scenario : str - Scenario name. - version : str or int or at.ac.iiasa.ixmp.objects.Scenario, optional - If omitted, load the default version of the (`model`, `scenario`). - If :class:`int`, load the specified version. - If ``'new'``, initialize a new Scenario. scheme : str, optional Use an explicit scheme for initializing a new scenario. - annotation : str, optional - A short annotation/comment used when ``version='new'``. cache : bool, optional Store data in memory and return cached values instead of repeatedly - querying the database. + querying the backend. """ _java_kwargs = { 'set': {}, @@ -1275,10 +1204,18 @@ def remove_solution(self, first_model_year=None): def solve(self, model=None, callback=None, cb_kwargs={}, **model_options): """Solve the model and store output. - ixmp 'solves' a model by invoking the ``run`` method of a - :class:`BaseModel` subclass—for instance, :meth:`GAMSModel.run`. - Depending on the underlying optimization software, different steps are - taken; see each model class for details. + ixmp 'solves' a model by invoking the run() method of a + :class:`Model ` subclass—for instance, + :meth:`GAMSModel.run `. + Depending on the underlying model code, different steps are taken; see + each model class for details. + In general: + + 1. Data from the Scenario are written to a **model input file**. + 2. Code or an external program is invoked to perform calculations or + optimizations, **solving the model**. + 3. Data representing the model outputs or solution are read from a + **model output file** and stored in the Scenario. If the optional argument `callback` is given, then additional steps are performed: @@ -1286,44 +1223,22 @@ def solve(self, model=None, callback=None, cb_kwargs={}, **model_options): 4. Execute the `callback` with the Scenario as an argument. The Scenario has an `iteration` attribute that stores the number of times the underlying model has been solved (#2). - 5. If the `callback` returns :obj:`False` or similar, go to #1; - otherwise exit. + 5. If the `callback` returns :obj:`False` or similar, iterate by + repeating from step #1. Otherwise, exit. Parameters ---------- model : str model (e.g., MESSAGE) or GAMS file name (excluding '.gms') - case : str - identifier of gdx file names, defaults to 'model_name_scen_name' - model_file : str, optional - path to GAMS file (including '.gms' extension) - in_file : str, optional - path to GAMS gdx input file (including '.gdx' extension) - out_file : str, optional - path to GAMS gdx output file (including '.gdx' extension) - solve_args : str, optional - arguments to be passed to GAMS (input/output file names, etc.) - comment : str, optional - additional comment added to changelog when importing the solution - var_list : list of str, optional - variables to be imported from the solution - equ_list : list of str, optional - equations to be imported from the solution - check_solution : boolean, optional - flag whether a non-optimal solution raises an exception - (only applies to MESSAGE runs) callback : callable, optional Method to execute arbitrary non-model code. Must accept a single - argument, the Scenario. Must return a non-:obj:`False` value to + argument: the Scenario. Must return a non-:obj:`False` value to indicate convergence. - gams_args : list of str, optional - Additional arguments for the CLI call to GAMS. See, e.g., - https://www.gams.com/latest/docs/UG_GamsCall.html#UG_GamsCall_ListOfCommandLineParameters - - - `LogOption=4` prints output to stdout (not console) and the log - file. cb_kwargs : dict, optional Keyword arguments to pass to `callback`. + model_options : + Keyword arguments specific to the `model`. See + :class:`GAMSModel `. Warns ----- diff --git a/ixmp/model/__init__.py b/ixmp/model/__init__.py index 12c7d55b3..6fda2e72b 100644 --- a/ixmp/model/__init__.py +++ b/ixmp/model/__init__.py @@ -1,15 +1,17 @@ from .gams import GAMSModel -#: Mapping from names to available backends +#: Mapping from names to available models. To register additional models, +#: add elements to this variable. MODELS = { 'default': GAMSModel, 'gams': GAMSModel, } -def get_model(name, **args): +def get_model(name, **model_options): + """Return a model for *name* (or the default) with *model_options*.""" try: - return MODELS[name](**args) + return MODELS[name](**model_options) except KeyError: - return MODELS['default'](name=name, **args) + return MODELS['default'](name=name, **model_options) diff --git a/ixmp/model/base.py b/ixmp/model/base.py index 2de6f16eb..3bb08635c 100644 --- a/ixmp/model/base.py +++ b/ixmp/model/base.py @@ -2,11 +2,29 @@ class Model(ABC): + #: Name of the model. + name = 'base' + @abstractmethod def __init__(self, name, **kwargs): + """Constructor. + + Parameters + ---------- + kwargs : + Model options, passed directly from :meth:`ixmp.Scenario.solve`. + + Model subclasses MUST document acceptable option values. + """ pass @abstractmethod def run(self, scenario): - """Execute the model.""" + """Execute the model. + + Parameters + ---------- + scenario : ixmp.Scenario + Scenario object to solve by running the Model. + """ pass diff --git a/ixmp/model/gams.py b/ixmp/model/gams.py index 88be16a90..3762decc2 100644 --- a/ixmp/model/gams.py +++ b/ixmp/model/gams.py @@ -9,55 +9,65 @@ class GAMSModel(Model): - """General class for ixmp models using GAMS solvers. + """General class for ixmp models using `GAMS `_. GAMSModel solves a Scenario using the following steps: 1. All Scenario data is written to a model input file in GDX format. - 2. A GAMS program is run to perform calculations. - 3. Output, or solution, data is read and stored in the Scenario. + 2. A GAMS program is run to perform calculations, producing output in a + GDX file. + 3. Output, or solution, data is read from the GDX file and stored in the + Scenario. - When created and then :meth:`run`, GAMSModel constructs file paths and - other necessary values using format strings. The default settings are those - in :attr:`config`; these may be overridden by the keyword arguments to - the constructor. + When created and :meth:`run`, GAMSModel constructs file paths and other + necessary values using format strings. The :attr:`defaults` may be + overridden by the keyword arguments to the constructor: Other parameters ---------------- - name : str - Override the :attr:`name` attribute. - model_file : str + name : str, optional + Override the :attr:`name` attribute to provide the `model_name` for + format strings. + model_file : str, optional Path to GAMS file, including '.gms' extension. - case : str + Default: ``'{model_name}.gms'`` (in the current directory). + case : str, optional Run or case identifier to use in GDX file names. Default: - '{scenario.model}_{scenario.name}', where *scenario* is the Scenario - object passed to :meth:`run`. Formatted using *model_file* and - *scenario*. - in_file : str - Path to write GDX input file. Formatted using *model_file*, - *scenario*, and *case*. - out_file : str - Path to read GDX output file. Formatted using *model_file*, - *scenario*, and *case*. - solve_args : list of str - Arguments to be passed to GAMS. Each formatted using *model_file*, - *scenario*, *case*, *in_file*, and *out_file*. - gams_args : list of str - Additional arguments passed directly to GAMS. See, e.g., - https://www.gams.com/latest/docs/UG_GamsCall.html#UG_GamsCall_ListOfCommandLineParameters - - - “LogOption=4” prints output to stdout (not console) and the log + ``'{scenario.model}_{scenario.name}'``, where `scenario` is the + :class:`ixmp.Scenario` object passed to :meth:`run`. + Formatted using `model_name` and `scenario`. + in_file : str, optional + Path to write GDX input file. Default: ``'{model_name}_in.gdx'``. + Formatted using `model_name`, `scenario`, and `case`. + out_file : str, optional + Path to read GDX output file. Default: ``'{model_name}_out.gdx'``. + Formatted using `model_name`, `scenario`, and `case`. + solve_args : list of str, optional + Arguments to be passed to GAMS, e.g. to identify the model input and + output files. Each formatted using `model_file`, `scenario`, `case`, + `in_file`, and `out_file`. Default: + + - ``'--in="{in_file}"'`` + - ``'--out="{out_file}"'`` + gams_args : list of str, optional + Additional arguments passed directly to GAMS without formatting, e.g. + to control solver options or behaviour. See the `GAMS + Documentation `_. + For example: + + - ``'LogOption=4'`` prints output to stdout (not console) and the log file. - check_solution : bool - If True, raise an exception if the GAMS solver did not reach + check_solution : bool, optional + If :obj:`True`, raise an exception if the GAMS solver did not reach optimality. (Only for MESSAGE-scheme Scenarios.) - comment : str - Comment added to Scenario when importing the solution. - equ_list : list of str - Equations to be imported from the *out_file*. - var_list : list of str - Variables to be imported from the *out_file*. - """ + comment : str, optional + Comment added to Scenario when importing the solution. If omitted, no + comment is added. + equ_list : list of str, optional + Equations to be imported from the `out_file`. Default: all. + var_list : list of str, optional + Variables to be imported from the `out_file`. Default: all. + """ # noqa: E501 #: Model name. name = 'default' From 1ff416b8b2b8d8e945e18d2d29e5968a26560ccf Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 15:31:30 +0200 Subject: [PATCH 47/71] Document base.Backend methods for Platform --- doc/source/api-backend.rst | 20 +- doc/source/api-model.rst | 4 +- ixmp/backend/base.py | 156 +++++++++--- ixmp/backend/jdbc.py | 20 +- ixmp/model/base.py | 473 ++++++++++++++++++++++++++++++++++++- 5 files changed, 612 insertions(+), 61 deletions(-) diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index b672b813b..5c9a6fa93 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -33,6 +33,8 @@ Provided backends It has the following methods that are not part of the overall :class:`Backend` API: .. autosummary:: + :nosignatures: + s_write_gdx s_read_gdx @@ -51,7 +53,10 @@ Backend API .. currentmodule:: ixmp.backend.base +.. autodata:: ixmp.backend.base.FIELDS + .. autoclass:: ixmp.backend.base.Backend + :members: In the following, the words REQUIRED, OPTIONAL, etc. have specific meanings as described in `IETF RFC 2119 `_. @@ -65,6 +70,8 @@ Backend API Methods related to :class:`ixmp.Platform`: .. autosummary:: + :nosignatures: + close_db get_auth get_nodes @@ -72,14 +79,17 @@ Backend API get_units open_db set_log_level - set_nodes + set_node set_unit Methods related to :class:`ixmp.TimeSeries`: + - Each method has an argument `ts`, a reference to the TimeSeries object being manipulated. - ‘Geodata’ is otherwise identical to regular timeseries data, except value are :class:`str` rather than :class:`float`. .. autosummary:: + :nosignatures: + ts_check_out ts_commit ts_delete @@ -98,7 +108,11 @@ Backend API Methods related to :class:`ixmp.Scenario`: + - Each method has an argument `s`, a reference to the Scenario object being manipulated. + .. autosummary:: + :nosignatures: + s_add_par_values s_add_set_elements s_clone @@ -115,7 +129,11 @@ Backend API Methods related to :class:`message_ix.Scenario`: + - Each method has an argument `ms`, a reference to the Scenario object being manipulated. + .. autosummary:: + :nosignatures: + ms_cat_get_elements ms_cat_list ms_cat_set_elements diff --git a/doc/source/api-model.rst b/doc/source/api-model.rst index 45da84a1d..17cf1cad9 100644 --- a/doc/source/api-model.rst +++ b/doc/source/api-model.rst @@ -1,7 +1,7 @@ .. currentmodule:: ixmp.model -Model formulations (:mod:`ixmp.model`) -====================================== +Mathematical models (:mod:`ixmp.model`) +======================================= By default, the |ixmp| is installed with :class:`ixmp.model.gams.GAMSModel`, which performs calculations by executing code stored in GAMS files. diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index c2e7726a2..83435a8cd 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -# List of field names for lists or tuples returned by Backend API methods +#: Lists of field names for tuples returned by Backend API methods. FIELDS = { 'get_nodes': ('region', 'mapped_to', 'parent', 'hierarchy'), 'get_scenarios': ('model', 'scenario', 'scheme', 'is_default', @@ -21,69 +21,98 @@ def __init__(self): """Initialize the backend.""" pass - @abstractmethod - def set_log_level(self, level): - """Set logging level for the backend and other code (required).""" - pass - - def open_db(self): - """(Re-)open a database connection (OPTIONAL). - - A backend MAY connect to a database server. This method opens the - database connection if it is closed. - """ - pass - def close_db(self): - """Close a database connection (OPTIONAL). + """OPTIONAL: Close database connection(s). - Close a database connection if it is open. + Close any database connection(s), if open. """ pass def get_auth(self, user, models, kind): - """Return user authorization for models (OPTIONAL). + """OPTIONAL: Return user authorization for *models*. + + If the Backend implements access control, + + Parameters + ---------- + user : str + User name or identifier. + models : list of str + Model names. + kind : str + Type of permission being requested - If the Backend implements access control… + Returns + ------- + dict + Mapping of `model name (str)` → `bool`; :obj:`True` if the user is + authorized for the model. """ return {model: True for model in models} - @abstractmethod - def set_node(self, name, parent=None, hierarchy=None, synonym=None): - pass - @abstractmethod def get_nodes(self): - """Iterate over all nodes (required). + """Iterate over all nodes stored on the Platform. Yields ------- tuple - The four members of each tuple are: - - 1. Name or synonym: str - 2. Name: str or None. - 3. Parent: str. - 4. Hierarchy: str. + The members of each tuple are: + + ========= =========== === + ID Type Description + ========= =========== === + region str Node name or synonym for node + mapped_to str or None Node name + parent str Parent node name + hierarchy str Node hierarchy ID + ========= =========== === """ pass @abstractmethod def get_scenarios(self, default, model, scenario): - pass + """Iterate over TimeSeries stored on the Platform. - @abstractmethod - def set_nodes(self): - # TODO document - pass + Scenarios, as subclasses of TimeSeries, are also included. - @abstractmethod - def set_unit(self, name, comment): + Parameters + ---------- + default : bool + :obj:`True` to include only TimeSeries versions marked as default. + model : str or None + Model name to filter results. + scenario : str or None + Scenario name to filter results. + + Yields + ------ + tuple + The members of each tuple are: + + ========== ==== === + ID Type Description + ========== ==== === + model str Model name. + scenario str Scenario name. + scheme str Scheme name. + is_default bool :obj:`True` if `version` is the default. + is_locked bool :obj:`True` if read-only. + cre_user str Name of user who created the TimeSeries. + cre_date str Creation datetime. + upd_user str Name of user who last modified the TimeSeries. + upd_date str Modification datetime. + lock_user str Name of user who locked the TimeSeries. + lock_date str Lock datetime. + annotation str Description of the TimeSeries. + version int Version. + ========== ==== === + """ pass @abstractmethod def get_units(self): - """Return all registered units of measurement (required). + """Return all registered units of measurement. Returns ------- @@ -91,6 +120,59 @@ def get_units(self): """ pass + def open_db(self): + """OPTIONAL: (Re-)open database connection(s). + + A backend MAY connect to a database server. This method opens the + database connection if it is closed. + """ + pass + + def set_log_level(self, level): + """OPTIONAL: Set logging level for the backend and other code. + + Parameters + ---------- + level : int or Python logging level + """ + pass + + @abstractmethod + def set_node(self, name, parent=None, hierarchy=None, synonym=None): + """Add a node name to the Platform. + + This method MUST be callable in one of two ways: + + - With `parent` and `hierarchy`: `name` is added as a child of `parent` + in the named `hierarchy`. + - With `synonym`: `synonym` is added as an alias for `name`. + + Parameters + ---------- + name : str + Node name. + parent : str, optional + Parent node name. + hierarchy : str, optional + Node hierarchy ID. + synonym : str, optional + Synonym for node. + """ + pass + + @abstractmethod + def set_unit(self, name, comment): + """Add a unit of measurement to the Platform. + + Parameters + ---------- + name : str + Symbol of the unit. + comment : str + Description of the change. + """ + pass + # Methods for ixmp.TimeSeries @abstractmethod diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index b55cb714c..faeb24371 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -45,12 +45,7 @@ class JDBCBackend(Backend): - """Backend using JPype and JDBC to connect to Oracle and HSQLDB instances. - - Among other abstractions, this backend: - - - Catches Java exceptions such as ixmp.exceptions.IxException, and - re-raises them as appropriate Python exceptions. + """Backend using JPype/JDBC to connect to Oracle and HyperSQLDB instances. Parameters ---------- @@ -74,8 +69,15 @@ class JDBCBackend(Backend): Java Virtual Machine arguments. See :meth:`ixmp.backend.jdbc.start_jvm`. """ - # NB Much of the code of this backend is implemented in Java code in the - # (private) iiasa/ixmp_source Github repository. + # NB Much of the code of this backend is in Java, in the iiasa/ixmp_source + # Github repository. + # + # Among other abstractions, this backend: + # + # - Handles any conversion between Java and Python types that is not + # done automatically by JPype. + # - Catches Java exceptions such as ixmp.exceptions.IxException, and + # re-raises them as appropriate Python exceptions. #: Reference to the at.ac.iiasa.ixmp.Platform Java object jobj = None @@ -554,7 +556,7 @@ def start_jvm(jvmargs=None): :meth:`jpype.startJVM`. For instance, to set the maximum heap space to 4 GiB, give - ``jvmargs=['-Xmx4G']``.See the `JVM documentation`_ for a list of + ``jvmargs=['-Xmx4G']``. See the `JVM documentation`_ for a list of options. .. _`JVM documentation`: https://docs.oracle.com/javase/7/docs diff --git a/ixmp/model/base.py b/ixmp/model/base.py index 3bb08635c..83435a8cd 100644 --- a/ixmp/model/base.py +++ b/ixmp/model/base.py @@ -1,30 +1,479 @@ from abc import ABC, abstractmethod -class Model(ABC): - #: Name of the model. - name = 'base' +#: Lists of field names for tuples returned by Backend API methods. +FIELDS = { + 'get_nodes': ('region', 'mapped_to', 'parent', 'hierarchy'), + 'get_scenarios': ('model', 'scenario', 'scheme', 'is_default', + 'is_locked', 'cre_user', 'cre_date', 'upd_user', + 'upd_date', 'lock_user', 'lock_date', 'annotation', + 'version'), + 'ts_get': ('region', 'variable', 'unit', 'year', 'value'), + 'ts_get_geo': ('region', 'variable', 'time', 'year', 'value', 'unit', + 'meta'), +} + + +class Backend(ABC): + """Abstract base class for backends.""" + + def __init__(self): + """Initialize the backend.""" + pass + + def close_db(self): + """OPTIONAL: Close database connection(s). + + Close any database connection(s), if open. + """ + pass + + def get_auth(self, user, models, kind): + """OPTIONAL: Return user authorization for *models*. + + If the Backend implements access control, + + Parameters + ---------- + user : str + User name or identifier. + models : list of str + Model names. + kind : str + Type of permission being requested + + Returns + ------- + dict + Mapping of `model name (str)` → `bool`; :obj:`True` if the user is + authorized for the model. + """ + return {model: True for model in models} + + @abstractmethod + def get_nodes(self): + """Iterate over all nodes stored on the Platform. + + Yields + ------- + tuple + The members of each tuple are: + + ========= =========== === + ID Type Description + ========= =========== === + region str Node name or synonym for node + mapped_to str or None Node name + parent str Parent node name + hierarchy str Node hierarchy ID + ========= =========== === + """ + pass + + @abstractmethod + def get_scenarios(self, default, model, scenario): + """Iterate over TimeSeries stored on the Platform. + + Scenarios, as subclasses of TimeSeries, are also included. + + Parameters + ---------- + default : bool + :obj:`True` to include only TimeSeries versions marked as default. + model : str or None + Model name to filter results. + scenario : str or None + Scenario name to filter results. + + Yields + ------ + tuple + The members of each tuple are: + + ========== ==== === + ID Type Description + ========== ==== === + model str Model name. + scenario str Scenario name. + scheme str Scheme name. + is_default bool :obj:`True` if `version` is the default. + is_locked bool :obj:`True` if read-only. + cre_user str Name of user who created the TimeSeries. + cre_date str Creation datetime. + upd_user str Name of user who last modified the TimeSeries. + upd_date str Modification datetime. + lock_user str Name of user who locked the TimeSeries. + lock_date str Lock datetime. + annotation str Description of the TimeSeries. + version int Version. + ========== ==== === + """ + pass + + @abstractmethod + def get_units(self): + """Return all registered units of measurement. + + Returns + ------- + list of str + """ + pass + + def open_db(self): + """OPTIONAL: (Re-)open database connection(s). + + A backend MAY connect to a database server. This method opens the + database connection if it is closed. + """ + pass + + def set_log_level(self, level): + """OPTIONAL: Set logging level for the backend and other code. + + Parameters + ---------- + level : int or Python logging level + """ + pass + + @abstractmethod + def set_node(self, name, parent=None, hierarchy=None, synonym=None): + """Add a node name to the Platform. + + This method MUST be callable in one of two ways: + + - With `parent` and `hierarchy`: `name` is added as a child of `parent` + in the named `hierarchy`. + - With `synonym`: `synonym` is added as an alias for `name`. + + Parameters + ---------- + name : str + Node name. + parent : str, optional + Parent node name. + hierarchy : str, optional + Node hierarchy ID. + synonym : str, optional + Synonym for node. + """ + pass + + @abstractmethod + def set_unit(self, name, comment): + """Add a unit of measurement to the Platform. + + Parameters + ---------- + name : str + Symbol of the unit. + comment : str + Description of the change. + """ + pass + + # Methods for ixmp.TimeSeries + + @abstractmethod + def ts_init(self, ts, annotation=None): + """Initialize the TimeSeries *ts* (required). + + The method MAY: + + - Modify the version attr of the returned object. + """ + pass @abstractmethod - def __init__(self, name, **kwargs): - """Constructor. + def ts_check_out(self, ts, timeseries_only): + """Check out the TimeSeries *s* for modifications (required). Parameters ---------- - kwargs : - Model options, passed directly from :meth:`ixmp.Scenario.solve`. + timeseries_only : bool + ??? + """ + pass - Model subclasses MUST document acceptable option values. + @abstractmethod + def ts_commit(self, ts, comment): + """Commit changes to the TimeSeries *s* (required). + + The method MAY: + + - Modify the version attr of *ts*. """ pass @abstractmethod - def run(self, scenario): - """Execute the model. + def ts_get(self, ts, region, variable, unit, year): + """Retrieve time-series data. Parameters ---------- - scenario : ixmp.Scenario - Scenario object to solve by running the Model. + region : list of str + variable : list of str + unit : list of str + year : list of str + + Yields + ------ + tuple + The five members of each tuple are: + + 1. region: str. + 2. variable: str. + 3. unit: str. + 4. year: int. + 5. value: float. """ pass + + @abstractmethod + def ts_get_geo(self, ts): + """Retrieve time-series 'geodata'. + + Yields + ------ + tuple + The seven members of each tuple are: + + 1. region: str. + 2. variable: str. + 3. time: str. + 4. year: int. + 5. value: str. + 6. unit: str. + 7. meta: int. + """ + pass + + @abstractmethod + def ts_set(self, ts, region, variable, data, unit, meta): + """Store time-series data. + + Parameters + ---------- + region, variable, time, unit : str + Indices for the data. + data : dict (int -> float) + Mapping from year to value. + meta : bool + Metadata flag. + """ + pass + + @abstractmethod + def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): + """Store time-series 'geodata'. + + Parameters + ---------- + region, variable, time, unit : str + Indices for the data. + year : int + Year index. + value : str + Data. + meta : bool + Metadata flag. + """ + pass + + @abstractmethod + def ts_delete(self, ts, region, variable, years, unit): + """Remove time-series data.""" + pass + + @abstractmethod + def ts_delete_geo(self, ts, region, variable, time, years, unit): + """Remove time-series 'geodata'.""" + pass + + @abstractmethod + def ts_discard_changes(self, ts): + # TODO document + pass + + @abstractmethod + def ts_set_as_default(self, ts): + # TODO document + pass + + @abstractmethod + def ts_is_default(self, ts): + # TODO document + pass + + @abstractmethod + def ts_last_update(self, ts): + # TODO document + pass + + @abstractmethod + def ts_run_id(self, ts): + # TODO document + pass + + @abstractmethod + def ts_preload(self, ts): + # TODO document + pass + + # Methods for ixmp.Scenario + + @abstractmethod + def s_clone(): + # TODO + pass + + @abstractmethod + def s_init(self, s, annotation=None): + """Initialize the Scenario *s* (required). + + The method MAY: + + - Modify the version attr of the returned object. + """ + pass + + @abstractmethod + def s_has_solution(self, s): + """Return :obj:`True` if Scenario *s* has been solved (required). + + If :obj:`True`, model solution data is available from the Backend. + """ + pass + + @abstractmethod + def s_list_items(self, s, type): + """Return a list of items of *type* in Scenario *s* (required).""" + pass + + @abstractmethod + def s_init_item(self, s, type, name): + """Initialize an item *name* of *type* in Scenario *s* (required).""" + pass + + @abstractmethod + def s_delete_item(self, s, type, name): + """Remove an item *name* of *type* in Scenario *s* (required).""" + pass + + @abstractmethod + def s_item_index(self, s, name, sets_or_names): + """Return the index sets or names of item *name* (required). + + Parameters + ---------- + sets_or_names : 'sets' or 'names' + """ + pass + + @abstractmethod + def s_item_elements(self, s, type, name, filters=None, has_value=False, + has_level=False): + """Return elements of item *name* in Scenario *s* (required). + + The return type varies according to the *type* and contents: + + - Scalars vs. parameters. + - Lists, e.g. set elements. + - Mapping sets. + - Multi-dimensional parameters, equations, or variables. + """ + # TODO exactly specify the return types in the docstring using MUST, + # MAY, etc. terms + pass + + @abstractmethod + def s_add_set_elements(self, s, name, elements): + """Add elements to set *name* in Scenario *s* (required). + + Parameters + ---------- + elements : iterable of 2-tuples + The tuple members are, respectively: + + 1. Key: str or list of str. The number and order of key dimensions + must match the index of *name*, if any. + 2. Comment: str or None. An optional description of the key. + + Raises + ------ + ValueError + If *elements* contain invalid values, e.g. for an indexed set, + values not in the index set(s). + Exception + If the Backend encounters any error adding the key. + """ + pass + + @abstractmethod + def s_add_par_values(self, s, name, elements): + """Add values to parameter *name* in Scenario *s* (required). + + Parameters + ---------- + elements : iterable of 4-tuples + The tuple members are, respectively: + + 1. Key: str or list of str or (for a scalar, or 0-dimensional + parameter) None. + 2. Value: float. + 3. Unit: str or None. + 4. Comment: str or None. + + Raises + ------ + ValueError + If *elements* contain invalid values, e.g. key values not in the + index set(s). + Exception + If the Backend encounters any error adding the parameter values. + """ + pass + + @abstractmethod + def s_item_delete_elements(self, s, type, name, key): + pass + + @abstractmethod + def s_get_meta(self, s): + pass + + @abstractmethod + def s_set_meta(self, s, name, value): + pass + + @abstractmethod + def s_clear_solution(self, s, from_year=None): + pass + + # Methods for message_ix.Scenario + + @abstractmethod + def ms_cat_list(self, ms, name): + """Return list of categories.""" + pass + + @abstractmethod + def ms_cat_get_elements(self, ms, name, cat): + """Get elements of a category mapping.""" + pass + + @abstractmethod + def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): + """Add elements to category mapping.""" + pass + + @abstractmethod + def ms_year_first_model(self, ms): + """Return the first model year.""" + pass + + @abstractmethod + def ms_years_active(self, ms, node, tec, year_vintage): + """Return a list of years in which *tec* is active.""" + pass From 2f1c58e3705198211b568caaddff41cc60318383 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 15:41:43 +0200 Subject: [PATCH 48/71] Revert accidental overwrite of model.base --- ixmp/model/base.py | 473 ++------------------------------------------- 1 file changed, 12 insertions(+), 461 deletions(-) diff --git a/ixmp/model/base.py b/ixmp/model/base.py index 83435a8cd..3bb08635c 100644 --- a/ixmp/model/base.py +++ b/ixmp/model/base.py @@ -1,479 +1,30 @@ from abc import ABC, abstractmethod -#: Lists of field names for tuples returned by Backend API methods. -FIELDS = { - 'get_nodes': ('region', 'mapped_to', 'parent', 'hierarchy'), - 'get_scenarios': ('model', 'scenario', 'scheme', 'is_default', - 'is_locked', 'cre_user', 'cre_date', 'upd_user', - 'upd_date', 'lock_user', 'lock_date', 'annotation', - 'version'), - 'ts_get': ('region', 'variable', 'unit', 'year', 'value'), - 'ts_get_geo': ('region', 'variable', 'time', 'year', 'value', 'unit', - 'meta'), -} - - -class Backend(ABC): - """Abstract base class for backends.""" - - def __init__(self): - """Initialize the backend.""" - pass - - def close_db(self): - """OPTIONAL: Close database connection(s). - - Close any database connection(s), if open. - """ - pass - - def get_auth(self, user, models, kind): - """OPTIONAL: Return user authorization for *models*. - - If the Backend implements access control, - - Parameters - ---------- - user : str - User name or identifier. - models : list of str - Model names. - kind : str - Type of permission being requested - - Returns - ------- - dict - Mapping of `model name (str)` → `bool`; :obj:`True` if the user is - authorized for the model. - """ - return {model: True for model in models} - - @abstractmethod - def get_nodes(self): - """Iterate over all nodes stored on the Platform. - - Yields - ------- - tuple - The members of each tuple are: - - ========= =========== === - ID Type Description - ========= =========== === - region str Node name or synonym for node - mapped_to str or None Node name - parent str Parent node name - hierarchy str Node hierarchy ID - ========= =========== === - """ - pass - - @abstractmethod - def get_scenarios(self, default, model, scenario): - """Iterate over TimeSeries stored on the Platform. - - Scenarios, as subclasses of TimeSeries, are also included. - - Parameters - ---------- - default : bool - :obj:`True` to include only TimeSeries versions marked as default. - model : str or None - Model name to filter results. - scenario : str or None - Scenario name to filter results. - - Yields - ------ - tuple - The members of each tuple are: - - ========== ==== === - ID Type Description - ========== ==== === - model str Model name. - scenario str Scenario name. - scheme str Scheme name. - is_default bool :obj:`True` if `version` is the default. - is_locked bool :obj:`True` if read-only. - cre_user str Name of user who created the TimeSeries. - cre_date str Creation datetime. - upd_user str Name of user who last modified the TimeSeries. - upd_date str Modification datetime. - lock_user str Name of user who locked the TimeSeries. - lock_date str Lock datetime. - annotation str Description of the TimeSeries. - version int Version. - ========== ==== === - """ - pass - - @abstractmethod - def get_units(self): - """Return all registered units of measurement. - - Returns - ------- - list of str - """ - pass - - def open_db(self): - """OPTIONAL: (Re-)open database connection(s). - - A backend MAY connect to a database server. This method opens the - database connection if it is closed. - """ - pass - - def set_log_level(self, level): - """OPTIONAL: Set logging level for the backend and other code. - - Parameters - ---------- - level : int or Python logging level - """ - pass - - @abstractmethod - def set_node(self, name, parent=None, hierarchy=None, synonym=None): - """Add a node name to the Platform. - - This method MUST be callable in one of two ways: - - - With `parent` and `hierarchy`: `name` is added as a child of `parent` - in the named `hierarchy`. - - With `synonym`: `synonym` is added as an alias for `name`. - - Parameters - ---------- - name : str - Node name. - parent : str, optional - Parent node name. - hierarchy : str, optional - Node hierarchy ID. - synonym : str, optional - Synonym for node. - """ - pass - - @abstractmethod - def set_unit(self, name, comment): - """Add a unit of measurement to the Platform. - - Parameters - ---------- - name : str - Symbol of the unit. - comment : str - Description of the change. - """ - pass - - # Methods for ixmp.TimeSeries - - @abstractmethod - def ts_init(self, ts, annotation=None): - """Initialize the TimeSeries *ts* (required). - - The method MAY: - - - Modify the version attr of the returned object. - """ - pass +class Model(ABC): + #: Name of the model. + name = 'base' @abstractmethod - def ts_check_out(self, ts, timeseries_only): - """Check out the TimeSeries *s* for modifications (required). + def __init__(self, name, **kwargs): + """Constructor. Parameters ---------- - timeseries_only : bool - ??? - """ - pass + kwargs : + Model options, passed directly from :meth:`ixmp.Scenario.solve`. - @abstractmethod - def ts_commit(self, ts, comment): - """Commit changes to the TimeSeries *s* (required). - - The method MAY: - - - Modify the version attr of *ts*. + Model subclasses MUST document acceptable option values. """ pass @abstractmethod - def ts_get(self, ts, region, variable, unit, year): - """Retrieve time-series data. + def run(self, scenario): + """Execute the model. Parameters ---------- - region : list of str - variable : list of str - unit : list of str - year : list of str - - Yields - ------ - tuple - The five members of each tuple are: - - 1. region: str. - 2. variable: str. - 3. unit: str. - 4. year: int. - 5. value: float. + scenario : ixmp.Scenario + Scenario object to solve by running the Model. """ pass - - @abstractmethod - def ts_get_geo(self, ts): - """Retrieve time-series 'geodata'. - - Yields - ------ - tuple - The seven members of each tuple are: - - 1. region: str. - 2. variable: str. - 3. time: str. - 4. year: int. - 5. value: str. - 6. unit: str. - 7. meta: int. - """ - pass - - @abstractmethod - def ts_set(self, ts, region, variable, data, unit, meta): - """Store time-series data. - - Parameters - ---------- - region, variable, time, unit : str - Indices for the data. - data : dict (int -> float) - Mapping from year to value. - meta : bool - Metadata flag. - """ - pass - - @abstractmethod - def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): - """Store time-series 'geodata'. - - Parameters - ---------- - region, variable, time, unit : str - Indices for the data. - year : int - Year index. - value : str - Data. - meta : bool - Metadata flag. - """ - pass - - @abstractmethod - def ts_delete(self, ts, region, variable, years, unit): - """Remove time-series data.""" - pass - - @abstractmethod - def ts_delete_geo(self, ts, region, variable, time, years, unit): - """Remove time-series 'geodata'.""" - pass - - @abstractmethod - def ts_discard_changes(self, ts): - # TODO document - pass - - @abstractmethod - def ts_set_as_default(self, ts): - # TODO document - pass - - @abstractmethod - def ts_is_default(self, ts): - # TODO document - pass - - @abstractmethod - def ts_last_update(self, ts): - # TODO document - pass - - @abstractmethod - def ts_run_id(self, ts): - # TODO document - pass - - @abstractmethod - def ts_preload(self, ts): - # TODO document - pass - - # Methods for ixmp.Scenario - - @abstractmethod - def s_clone(): - # TODO - pass - - @abstractmethod - def s_init(self, s, annotation=None): - """Initialize the Scenario *s* (required). - - The method MAY: - - - Modify the version attr of the returned object. - """ - pass - - @abstractmethod - def s_has_solution(self, s): - """Return :obj:`True` if Scenario *s* has been solved (required). - - If :obj:`True`, model solution data is available from the Backend. - """ - pass - - @abstractmethod - def s_list_items(self, s, type): - """Return a list of items of *type* in Scenario *s* (required).""" - pass - - @abstractmethod - def s_init_item(self, s, type, name): - """Initialize an item *name* of *type* in Scenario *s* (required).""" - pass - - @abstractmethod - def s_delete_item(self, s, type, name): - """Remove an item *name* of *type* in Scenario *s* (required).""" - pass - - @abstractmethod - def s_item_index(self, s, name, sets_or_names): - """Return the index sets or names of item *name* (required). - - Parameters - ---------- - sets_or_names : 'sets' or 'names' - """ - pass - - @abstractmethod - def s_item_elements(self, s, type, name, filters=None, has_value=False, - has_level=False): - """Return elements of item *name* in Scenario *s* (required). - - The return type varies according to the *type* and contents: - - - Scalars vs. parameters. - - Lists, e.g. set elements. - - Mapping sets. - - Multi-dimensional parameters, equations, or variables. - """ - # TODO exactly specify the return types in the docstring using MUST, - # MAY, etc. terms - pass - - @abstractmethod - def s_add_set_elements(self, s, name, elements): - """Add elements to set *name* in Scenario *s* (required). - - Parameters - ---------- - elements : iterable of 2-tuples - The tuple members are, respectively: - - 1. Key: str or list of str. The number and order of key dimensions - must match the index of *name*, if any. - 2. Comment: str or None. An optional description of the key. - - Raises - ------ - ValueError - If *elements* contain invalid values, e.g. for an indexed set, - values not in the index set(s). - Exception - If the Backend encounters any error adding the key. - """ - pass - - @abstractmethod - def s_add_par_values(self, s, name, elements): - """Add values to parameter *name* in Scenario *s* (required). - - Parameters - ---------- - elements : iterable of 4-tuples - The tuple members are, respectively: - - 1. Key: str or list of str or (for a scalar, or 0-dimensional - parameter) None. - 2. Value: float. - 3. Unit: str or None. - 4. Comment: str or None. - - Raises - ------ - ValueError - If *elements* contain invalid values, e.g. key values not in the - index set(s). - Exception - If the Backend encounters any error adding the parameter values. - """ - pass - - @abstractmethod - def s_item_delete_elements(self, s, type, name, key): - pass - - @abstractmethod - def s_get_meta(self, s): - pass - - @abstractmethod - def s_set_meta(self, s, name, value): - pass - - @abstractmethod - def s_clear_solution(self, s, from_year=None): - pass - - # Methods for message_ix.Scenario - - @abstractmethod - def ms_cat_list(self, ms, name): - """Return list of categories.""" - pass - - @abstractmethod - def ms_cat_get_elements(self, ms, name, cat): - """Get elements of a category mapping.""" - pass - - @abstractmethod - def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): - """Add elements to category mapping.""" - pass - - @abstractmethod - def ms_year_first_model(self, ms): - """Return the first model year.""" - pass - - @abstractmethod - def ms_years_active(self, ms, node, tec, year_vintage): - """Return a list of years in which *tec* is active.""" - pass From d415df4aca5d99e9f31560033e031d405cade875 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 25 Oct 2019 17:23:47 +0200 Subject: [PATCH 49/71] Handle clone limitations in JDBCBackend --- ixmp/backend/base.py | 68 +++++++++++++++++++++++++-------------- ixmp/backend/jdbc.py | 17 ++++++++-- ixmp/core.py | 24 ++++++++------ tests/test_integration.py | 11 ++++--- 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 83435a8cd..d008cab06 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -177,7 +177,7 @@ def set_unit(self, name, comment): @abstractmethod def ts_init(self, ts, annotation=None): - """Initialize the TimeSeries *ts* (required). + """Initialize the TimeSeries *ts*. The method MAY: @@ -187,7 +187,7 @@ def ts_init(self, ts, annotation=None): @abstractmethod def ts_check_out(self, ts, timeseries_only): - """Check out the TimeSeries *s* for modifications (required). + """Check out *ts* for modifications. Parameters ---------- @@ -198,7 +198,7 @@ def ts_check_out(self, ts, timeseries_only): @abstractmethod def ts_commit(self, ts, comment): - """Commit changes to the TimeSeries *s* (required). + """Commit changes to *ts*. The method MAY: @@ -251,7 +251,7 @@ def ts_get_geo(self, ts): @abstractmethod def ts_set(self, ts, region, variable, data, unit, meta): - """Store time-series data. + """Store *data* in *ts*. Parameters ---------- @@ -283,54 +283,70 @@ def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): @abstractmethod def ts_delete(self, ts, region, variable, years, unit): - """Remove time-series data.""" + """Remove data values from *ts*.""" pass @abstractmethod def ts_delete_geo(self, ts, region, variable, time, years, unit): - """Remove time-series 'geodata'.""" + """Remove 'geodata' values from *ts*.""" pass @abstractmethod def ts_discard_changes(self, ts): - # TODO document + """Discard changes to *ts* since the last commit.""" pass @abstractmethod def ts_set_as_default(self, ts): - # TODO document + """Set *ts* as the default version.""" pass @abstractmethod def ts_is_default(self, ts): - # TODO document + """Return :obj:`True` if *ts* is the default version. + + Returns + ------- + bool + """ pass @abstractmethod def ts_last_update(self, ts): - # TODO document + """Return the date of the last modification of the *ts*.""" pass @abstractmethod def ts_run_id(self, ts): - # TODO document + """Return the run ID for the *ts*.""" pass - @abstractmethod def ts_preload(self, ts): - # TODO document + """OPTIONAL: Load *ts* data into memory.""" pass # Methods for ixmp.Scenario @abstractmethod - def s_clone(): - # TODO + def s_clone(self, s, target_backend, model, scenario, annotation, + keep_solution, first_model_year=None): + """Clone *s*. + + Parameters + ---------- + target_backend : :class:`ixmp.backend.base.Backend` + Target backend. May be the same as `s.platform._backend`. + model : str + scenario : str + annotation : str + keep_solution : bool + first_model_year : int or None + """ pass @abstractmethod def s_init(self, s, annotation=None): - """Initialize the Scenario *s* (required). + """Initialize the Scenario *s*. The method MAY: @@ -340,7 +356,7 @@ def s_init(self, s, annotation=None): @abstractmethod def s_has_solution(self, s): - """Return :obj:`True` if Scenario *s* has been solved (required). + """Return :obj:`True` if Scenario *s* has been solved. If :obj:`True`, model solution data is available from the Backend. """ @@ -348,22 +364,22 @@ def s_has_solution(self, s): @abstractmethod def s_list_items(self, s, type): - """Return a list of items of *type* in Scenario *s* (required).""" + """Return a list of items of *type* in Scenario *s*.""" pass @abstractmethod def s_init_item(self, s, type, name): - """Initialize an item *name* of *type* in Scenario *s* (required).""" + """Initialize an item *name* of *type* in Scenario *s*.""" pass @abstractmethod def s_delete_item(self, s, type, name): - """Remove an item *name* of *type* in Scenario *s* (required).""" + """Remove an item *name* of *type* in Scenario *s*.""" pass @abstractmethod def s_item_index(self, s, name, sets_or_names): - """Return the index sets or names of item *name* (required). + """Return the index sets or names of item *name*. Parameters ---------- @@ -374,7 +390,7 @@ def s_item_index(self, s, name, sets_or_names): @abstractmethod def s_item_elements(self, s, type, name, filters=None, has_value=False, has_level=False): - """Return elements of item *name* in Scenario *s* (required). + """Return elements of item *name* in Scenario *s*. The return type varies according to the *type* and contents: @@ -389,7 +405,7 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, @abstractmethod def s_add_set_elements(self, s, name, elements): - """Add elements to set *name* in Scenario *s* (required). + """Add elements to set *name* in Scenario *s*. Parameters ---------- @@ -412,7 +428,7 @@ def s_add_set_elements(self, s, name, elements): @abstractmethod def s_add_par_values(self, s, name, elements): - """Add values to parameter *name* in Scenario *s* (required). + """Add values to parameter *name* in Scenario *s*. Parameters ---------- @@ -437,18 +453,22 @@ def s_add_par_values(self, s, name, elements): @abstractmethod def s_item_delete_elements(self, s, type, name, key): + """Remove elements of item *name*.""" pass @abstractmethod def s_get_meta(self, s): + """Return all metadata.""" pass @abstractmethod def s_set_meta(self, s, name, value): + """Set a single metadata key.""" pass @abstractmethod def s_clear_solution(self, s, from_year=None): + """Remove data associated with a model solution.""" pass # Methods for message_ix.Scenario diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index faeb24371..bea1fc55b 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -78,6 +78,10 @@ class JDBCBackend(Backend): # done automatically by JPype. # - Catches Java exceptions such as ixmp.exceptions.IxException, and # re-raises them as appropriate Python exceptions. + # + # Limitations: + # + # - s_clone() is only supported when target_backend is JDBCBackend. #: Reference to the at.ac.iiasa.ixmp.Platform Java object jobj = None @@ -316,13 +320,22 @@ def s_init(self, s, scheme=None, annotation=None): def s_clone(self, s, target_backend, model, scenario, annotation, keep_solution, first_model_year=None): + # Raise exceptions for limitations of JDBCBackend if not isinstance(target_backend, self.__class__): - raise RuntimeError('Clone only possible between two instances of' - f'{self.__class__.__name__}') + raise NotImplementedError(f'Clone between {self.__class__} and' + f'{target_backend.__class__}') + elif target_backend is not self: + msg = 'Cross-platform clone of {}.Scenario with'.format( + s.__class__.__module__.split('.')[0]) + if keep_solution is False: + raise NotImplementedError(msg + ' `keep_solution=False`') + elif 'message_ix' in msg and first_model_year is not None: + raise NotImplementedError(msg + ' first_model_year != None') args = [model, scenario, annotation, keep_solution] if first_model_year: args.append(first_model_year) + # Reference to the cloned Java object return self.jindex[s].clone(target_backend.jobj, *args) diff --git a/ixmp/core.py b/ixmp/core.py index 112ddbebe..226450c42 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1097,7 +1097,7 @@ def equ(self, name, filters=None, **kwargs): return self._element('equ', name, filters, **kwargs) def clone(self, model=None, scenario=None, annotation=None, - keep_solution=True, first_model_year=None, platform=None, + keep_solution=True, shift_first_model_year=None, platform=None, **kwargs): """Clone the current scenario and return the clone. @@ -1143,21 +1143,27 @@ def clone(self, model=None, scenario=None, annotation=None, ' release, please use `scenario`') scenario = kwargs.pop('scen') - if keep_solution and first_model_year is not None: - raise ValueError('Use `keep_solution=False` when cloning with ' - '`first_model_year`!') + if 'first_model_year' in kwargs: + warn('`first_model_year` is deprecated and will be removed in the' + ' next release. Use `shift_first_model_year`.') + shift_first_model_year = kwargs.pop('first_model_year') - if platform is not None and not keep_solution: - raise ValueError('Cloning across platforms is only possible ' - 'with `keep_solution=True`!') + if len(kwargs): + raise ValueError('Invalid arguments {!r}'.format(kwargs)) + + if shift_first_model_year is not None: + if keep_solution: + logger().warn('Overriding keep_solution=True for ' + 'shift_first_model_year') + keep_solution = False platform = platform or self.platform model = model or self.model scenario = scenario or self.scenario args = [platform._backend, model, scenario, annotation, keep_solution] - if check_year(first_model_year, 'first_model_year'): - args.append(first_model_year) + if check_year(shift_first_model_year, 'first_model_year'): + args.append(shift_first_model_year) scenario_class = self.__class__ diff --git a/tests/test_integration.py b/tests/test_integration.py index a1788f04f..9dc8cb08c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -10,7 +10,7 @@ TS_DF_CLEARED.loc[0, 2005] = np.nan -def test_run_clone(tmpdir, test_data_path): +def test_run_clone(tmpdir, test_data_path, caplog): # this test is designed to cover the full functionality of the GAMS API # - initialize a new platform instance # - creates a new scenario and exports a gdx file @@ -27,8 +27,10 @@ def test_run_clone(tmpdir, test_data_path): assert np.isclose(scen2.var('z')['lvl'], 153.675) pdt.assert_frame_equal(scen2.timeseries(iamc=True), TS_DF) - # cloning with `keep_solution=True` and `first_model_year` raises an error - pytest.raises(ValueError, scen.clone, first_model_year=2005) + # cloning with `keep_solution=True` and `first_model_year` raises a warning + scen.clone(keep_solution=True, shift_first_model_year=2005) + assert ('Overriding keep_solution=True for shift_first_model_year' + == caplog.records[-1].message) # cloning with `keep_solution=False` drops the solution and only keeps # timeseries set as `meta=True` @@ -100,7 +102,8 @@ def test_multi_db_run(tmpdir, test_data_path): mp2.add_region('wrong_region', 'country') # check that cloning across platforms must copy the full solution - pytest.raises(ValueError, scen1.clone, platform=mp2, keep_solution=False) + pytest.raises(NotImplementedError, scen1.clone, platform=mp2, + keep_solution=False) # clone solved model across platforms (with default settings) scen1.clone(platform=mp2, keep_solution=True) From 3eecdfb909daf3c3c5b6b85c802d26975933f680 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sat, 26 Oct 2019 12:48:53 +0200 Subject: [PATCH 50/71] Test reporting.RENAME_DIMS --- ixmp/reporting/__init__.py | 2 +- ixmp/reporting/utils.py | 19 +++++++++++-------- tests/test_reporting.py | 30 ++++++++++++++++++++++++------ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/ixmp/reporting/__init__.py b/ixmp/reporting/__init__.py index 2d4ed5552..f614dba5c 100644 --- a/ixmp/reporting/__init__.py +++ b/ixmp/reporting/__init__.py @@ -611,7 +611,7 @@ def _config_args(path, keys, sections={}): extra_sections = set(result.keys()) - sections - {'config_dir'} if len(extra_sections): warn(('Unrecognized sections {!r} in reporting configuration will ' - 'have no effect').format(extra_sections)) + 'have no effect').format(sorted(extra_sections))) return result diff --git a/ixmp/reporting/utils.py b/ixmp/reporting/utils.py index 6e102d7e2..a973a9bee 100644 --- a/ixmp/reporting/utils.py +++ b/ixmp/reporting/utils.py @@ -65,8 +65,14 @@ def collect_units(*args): return [arg.attrs['_unit'] for arg in args] -def _find_dims(data): - """Return the list of dimensions for *data*.""" +def dims_for_qty(data): + """Return the list of dimensions for *data*. + + If *data* is a :class:`pandas.DataFrame`, its columns are processed; + otherwise it must be a list. + + ixmp.reporting.RENAME_DIMS is used to rename dimensions. + """ if isinstance(data, pd.DataFrame): # List of the dimensions dims = data.columns.tolist() @@ -86,11 +92,8 @@ def _find_dims(data): def keys_for_quantity(ix_type, name, scenario): """Iterate over keys for *name* in *scenario*.""" - # Retrieve names of the indices of the low-level/Java object, *without* - # loading the associated data - # NB this is used instead of .getIdxSets, since the same set may index more - # than one dimension of the same variable. - dims = scenario.idx_names(name) + # Retrieve names of the indices of the ixmp item, without loading the data + dims = dims_for_qty(scenario.idx_names(name)) # Column for retrieving data column = 'value' if ix_type == 'par' else 'lvl' @@ -194,7 +197,7 @@ def data_for_quantity(ix_type, name, column, scenario, filters=None): data = pd.DataFrame.from_records([data]) # List of the dimensions - dims = _find_dims(data) + dims = dims_for_qty(data) # Remove the unit from the DataFrame try: diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 019bd12b5..c7c6f8c37 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -11,14 +11,16 @@ import ixmp.reporting from ixmp.reporting import ( + UNITS, + RENAME_DIMS, ComputationError, KeyExistsError, MissingKeyError, Key, Reporter, + configure, computations, ) -from ixmp.reporting import UNITS from ixmp.reporting.utils import Quantity from ixmp.testing import make_dantzig, assert_qty_allclose, assert_qty_equal @@ -41,10 +43,24 @@ def scenario(test_mp): return scen -def test_reporting_configure(): - # TODO test: All supported configuration keys can be handled - # TODO test: Unsupported keys raise warnings or errors - pass +def test_reporting_configure(test_mp, test_data_path): + # TODO test: configuration keys 'units', 'replace_units' + + # Configure globally; reads 'rename_dims' section + configure(rename_dims={'i': 'i_renamed'}) + + # Reporting uses the RENAME_DIMS mapping of 'i' to 'i_renamed' + scen = make_dantzig(test_mp) + rep = Reporter.from_scenario(scen) + assert 'd:i_renamed-j' in rep, rep.graph.keys() + assert ['seattle', 'new-york'] == rep.get('i_renamed') + + # Original name 'i' are not found in the reporter + assert 'd:i-j' not in rep, rep.graph.keys() + pytest.raises(KeyError, rep.get, 'i') + + # Remove the configuration for renaming 'i', so that other tests work + RENAME_DIMS.pop('i') def test_reporter_add(): @@ -168,8 +184,10 @@ def test_reporter_read_config(test_mp, test_data_path): scen = make_dantzig(test_mp) rep = Reporter.from_scenario(scen) + + # Warning is raised when reading configuration with unrecognized section(s) with pytest.warns(UserWarning, - match=r"Unrecognized sections {'notarealsection'}"): + match=r"Unrecognized sections \['notarealsection'\]"): rep.read_config(test_data_path / 'report-config-0.yaml') # Data from configured file is available From 88c614d043967c53029e2e4b7811080a93457124 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sat, 26 Oct 2019 13:35:06 +0200 Subject: [PATCH 51/71] Also use RENAME_DIMS in Reporter.from_scenario() --- ixmp/reporting/__init__.py | 2 +- tests/test_reporting.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ixmp/reporting/__init__.py b/ixmp/reporting/__init__.py index f614dba5c..ecf189740 100644 --- a/ixmp/reporting/__init__.py +++ b/ixmp/reporting/__init__.py @@ -140,7 +140,7 @@ def from_scenario(cls, scenario, **kwargs): except AttributeError: # pd.DataFrame for a multidimensional set; store as-is pass - rep.add(name, elements) + rep.add(RENAME_DIMS.get(name, name), elements) # Add the scenario itself rep.add('scenario', scenario) diff --git a/tests/test_reporting.py b/tests/test_reporting.py index c7c6f8c37..6d3f0a6d3 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -53,7 +53,7 @@ def test_reporting_configure(test_mp, test_data_path): scen = make_dantzig(test_mp) rep = Reporter.from_scenario(scen) assert 'd:i_renamed-j' in rep, rep.graph.keys() - assert ['seattle', 'new-york'] == rep.get('i_renamed') + assert ['seattle', 'san-diego'] == rep.get('i_renamed') # Original name 'i' are not found in the reporter assert 'd:i-j' not in rep, rep.graph.keys() From b4a7402f56ac82fe83dd3a40bfcbf0ff2cf67a25 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sat, 26 Oct 2019 15:00:16 +0200 Subject: [PATCH 52/71] Remove pass in base.Backend abstract methods --- ixmp/backend/base.py | 54 ++++++++------------------------------------ 1 file changed, 10 insertions(+), 44 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index d008cab06..f2fb10621 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -16,19 +16,23 @@ class Backend(ABC): """Abstract base class for backends.""" + # NB non-abstract methods like close_db() are marked '# pragma: no cover'. + # In order to cover these with tests, define a MemoryBackend or similar + # that provides implementations of all the abstract methods but does + # NOT override the non-abstract methods; then call those. - def __init__(self): + def __init__(self): # pragma: no cover """Initialize the backend.""" pass - def close_db(self): + def close_db(self): # pragma: no cover """OPTIONAL: Close database connection(s). Close any database connection(s), if open. """ pass - def get_auth(self, user, models, kind): + def get_auth(self, user, models, kind): # pragma: no cover """OPTIONAL: Return user authorization for *models*. If the Backend implements access control, @@ -68,7 +72,6 @@ def get_nodes(self): hierarchy str Node hierarchy ID ========= =========== === """ - pass @abstractmethod def get_scenarios(self, default, model, scenario): @@ -108,7 +111,6 @@ def get_scenarios(self, default, model, scenario): version int Version. ========== ==== === """ - pass @abstractmethod def get_units(self): @@ -118,9 +120,8 @@ def get_units(self): ------- list of str """ - pass - def open_db(self): + def open_db(self): # pragma: no cover """OPTIONAL: (Re-)open database connection(s). A backend MAY connect to a database server. This method opens the @@ -128,7 +129,7 @@ def open_db(self): """ pass - def set_log_level(self, level): + def set_log_level(self, level): # pragma: no cover """OPTIONAL: Set logging level for the backend and other code. Parameters @@ -158,7 +159,6 @@ def set_node(self, name, parent=None, hierarchy=None, synonym=None): synonym : str, optional Synonym for node. """ - pass @abstractmethod def set_unit(self, name, comment): @@ -171,7 +171,6 @@ def set_unit(self, name, comment): comment : str Description of the change. """ - pass # Methods for ixmp.TimeSeries @@ -183,7 +182,6 @@ def ts_init(self, ts, annotation=None): - Modify the version attr of the returned object. """ - pass @abstractmethod def ts_check_out(self, ts, timeseries_only): @@ -194,7 +192,6 @@ def ts_check_out(self, ts, timeseries_only): timeseries_only : bool ??? """ - pass @abstractmethod def ts_commit(self, ts, comment): @@ -204,7 +201,6 @@ def ts_commit(self, ts, comment): - Modify the version attr of *ts*. """ - pass @abstractmethod def ts_get(self, ts, region, variable, unit, year): @@ -228,7 +224,6 @@ def ts_get(self, ts, region, variable, unit, year): 4. year: int. 5. value: float. """ - pass @abstractmethod def ts_get_geo(self, ts): @@ -247,7 +242,6 @@ def ts_get_geo(self, ts): 6. unit: str. 7. meta: int. """ - pass @abstractmethod def ts_set(self, ts, region, variable, data, unit, meta): @@ -262,7 +256,6 @@ def ts_set(self, ts, region, variable, data, unit, meta): meta : bool Metadata flag. """ - pass @abstractmethod def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): @@ -279,27 +272,22 @@ def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): meta : bool Metadata flag. """ - pass @abstractmethod def ts_delete(self, ts, region, variable, years, unit): """Remove data values from *ts*.""" - pass @abstractmethod def ts_delete_geo(self, ts, region, variable, time, years, unit): """Remove 'geodata' values from *ts*.""" - pass @abstractmethod def ts_discard_changes(self, ts): """Discard changes to *ts* since the last commit.""" - pass @abstractmethod def ts_set_as_default(self, ts): """Set *ts* as the default version.""" - pass @abstractmethod def ts_is_default(self, ts): @@ -309,19 +297,16 @@ def ts_is_default(self, ts): ------- bool """ - pass @abstractmethod def ts_last_update(self, ts): """Return the date of the last modification of the *ts*.""" - pass @abstractmethod def ts_run_id(self, ts): """Return the run ID for the *ts*.""" - pass - def ts_preload(self, ts): + def ts_preload(self, ts): # pragma: no cover """OPTIONAL: Load *ts* data into memory.""" pass @@ -342,7 +327,6 @@ def s_clone(self, s, target_backend, model, scenario, annotation, keep_solution : bool first_model_year : int or None """ - pass @abstractmethod def s_init(self, s, annotation=None): @@ -352,7 +336,6 @@ def s_init(self, s, annotation=None): - Modify the version attr of the returned object. """ - pass @abstractmethod def s_has_solution(self, s): @@ -360,22 +343,18 @@ def s_has_solution(self, s): If :obj:`True`, model solution data is available from the Backend. """ - pass @abstractmethod def s_list_items(self, s, type): """Return a list of items of *type* in Scenario *s*.""" - pass @abstractmethod def s_init_item(self, s, type, name): """Initialize an item *name* of *type* in Scenario *s*.""" - pass @abstractmethod def s_delete_item(self, s, type, name): """Remove an item *name* of *type* in Scenario *s*.""" - pass @abstractmethod def s_item_index(self, s, name, sets_or_names): @@ -385,7 +364,6 @@ def s_item_index(self, s, name, sets_or_names): ---------- sets_or_names : 'sets' or 'names' """ - pass @abstractmethod def s_item_elements(self, s, type, name, filters=None, has_value=False, @@ -401,7 +379,6 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, """ # TODO exactly specify the return types in the docstring using MUST, # MAY, etc. terms - pass @abstractmethod def s_add_set_elements(self, s, name, elements): @@ -424,7 +401,6 @@ def s_add_set_elements(self, s, name, elements): Exception If the Backend encounters any error adding the key. """ - pass @abstractmethod def s_add_par_values(self, s, name, elements): @@ -449,51 +425,41 @@ def s_add_par_values(self, s, name, elements): Exception If the Backend encounters any error adding the parameter values. """ - pass @abstractmethod def s_item_delete_elements(self, s, type, name, key): """Remove elements of item *name*.""" - pass @abstractmethod def s_get_meta(self, s): """Return all metadata.""" - pass @abstractmethod def s_set_meta(self, s, name, value): """Set a single metadata key.""" - pass @abstractmethod def s_clear_solution(self, s, from_year=None): """Remove data associated with a model solution.""" - pass # Methods for message_ix.Scenario @abstractmethod def ms_cat_list(self, ms, name): """Return list of categories.""" - pass @abstractmethod def ms_cat_get_elements(self, ms, name, cat): """Get elements of a category mapping.""" - pass @abstractmethod def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): """Add elements to category mapping.""" - pass @abstractmethod def ms_year_first_model(self, ms): """Return the first model year.""" - pass @abstractmethod def ms_years_active(self, ms, node, tec, year_vintage): """Return a list of years in which *tec* is active.""" - pass From b2fea0525b2c03d0c62c0c6fdd1cc2cd951d1b6c Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sat, 26 Oct 2019 15:08:49 +0200 Subject: [PATCH 53/71] Update ixmp._version.get_config to match setup.cfg --- ixmp/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ixmp/_version.py b/ixmp/_version.py index d1d4fbcd3..f5206fab9 100644 --- a/ixmp/_version.py +++ b/ixmp/_version.py @@ -40,7 +40,7 @@ def get_config(): # _version.py cfg = VersioneerConfig() cfg.VCS = "git" - cfg.style = "pep440" + cfg.style = "pep440-pre" cfg.tag_prefix = "v" cfg.parentdir_prefix = "ixmp-" cfg.versionfile_source = "ixmp/_version.py" From 99cf7f4c04cee57669c3e000a986667728ee97a7 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sat, 26 Oct 2019 15:42:29 +0200 Subject: [PATCH 54/71] Remove unused utils.harmonize_path --- ixmp/utils.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/ixmp/utils.py b/ixmp/utils.py index 13b9e07e8..bbaf4b402 100644 --- a/ixmp/utils.py +++ b/ixmp/utils.py @@ -1,10 +1,7 @@ import collections import logging import os -try: - from pathlib import Path -except ImportError: - from pathlib2 import Path +from pathlib import Path import pandas as pd import six @@ -144,15 +141,3 @@ def import_timeseries(mp, data, model, scenario, version=None, if lastyear is not None: annot = '{} until {}'.format(annot, lastyear) scen.commit(annot) - - -def harmonize_path(path_or_str): - """Harmonize mixed '\' and '/' separators in pathlib.Path or str. - - On Windows, R's file.path(...) uses '/', not '\', as a path separator. - Python's str(WindowsPath(...)) uses '\'. Mixing outputs from the two - functions (e.g. through rixmp) produces path strings with both kinds of - separators. - """ - args = ('/', '\\') if os.name == 'nt' else ('\\', '/') - return Path(str(path_or_str).replace(*args)) From 962f739bf71a1718b1c3fdf8f6e73e983986fc6e Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sat, 26 Oct 2019 16:00:03 +0200 Subject: [PATCH 55/71] Expand test_set() to cover remove_set(), Scenario._keys() --- tests/test_core.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 419a021f8..a7c7cf212 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -123,8 +123,8 @@ def test_init_set(test_mp): scen.init_set('foo') -def test_add_set(test_mp): - """Test ixmp.Scenario.add_set().""" +def test_set(test_mp): + """Test ixmp.Scenario.add_set(), .set(), and .remove_set().""" scen = ixmp.Scenario(test_mp, *can_args) # Add element to a non-existent set @@ -187,6 +187,28 @@ def test_add_set(test_mp): "element 'bar'!"): scen.add_set('foo', 'bar') + # Retrieve set elements + i = {'seattle', 'san-diego', 'i1', 'i2', 'i3', 'i4', 'i5', '0', '1', '2', + 'i6', 'i7', 'i8'} + assert i == set(scen.set('i')) + + # Remove elements from an 0D set: bare name + scen.remove_set('i', 'i2') + i -= {'i2'} + assert i == set(scen.set('i')) + + # Remove elements from an 0D set: Iterable of names, length >1 + scen.remove_set('i', ['i4', 'i5']) + i -= {'i4', 'i5'} + assert i == set(scen.set('i')) + + # Remove elements from a 1D set: Dict + scen.remove_set('foo', dict(dim_i=['i7', 'i8'])) + # Added elements from above; minus directly removed elements; minus i2 + # because it was removed from the set i that indexes dim_i of foo + foo = {'i1', 'i2', 'i3', 'i6', 'i7', 'i8'} - {'i2'} - {'i7', 'i8'} + assert foo == set(scen.set('foo')['dim_i']) + # make sure that changes to a scenario are copied over during clone def test_add_clone(test_mp): From bf1d433661ab6cf3134a0813c1d265cec7285bb3 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 00:01:23 +0200 Subject: [PATCH 56/71] Document more Backend methods --- doc/source/api-backend.rst | 21 +- doc/source/conf.py | 8 +- ixmp/backend/base.py | 459 ++++++++++++++++++++++++++++++------- ixmp/backend/jdbc.py | 96 ++++---- ixmp/core.py | 72 +++--- ixmp/model/base.py | 4 +- ixmp/model/gams.py | 2 +- 7 files changed, 480 insertions(+), 182 deletions(-) diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index 5c9a6fa93..5fc448758 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -48,7 +48,7 @@ Backend API - In contrast, :class:`Backend` has a *very simple* API that accepts arguments and returns values in basic Python data types and structures. - As a result: - - :class:`Platform ` code does is not affected by where and how data is stored; it merely handles user arguments and then makes, usually, a single :class:`Backend` call. + - :class:`Platform ` code is not affected by where and how data is stored; it merely handles user arguments and then makes, usually, a single :class:`Backend` call. - :class:`Backend` code does not need to perform argument checking; merely store and retrieve data reliably. .. currentmodule:: ixmp.backend.base @@ -58,14 +58,18 @@ Backend API .. autoclass:: ixmp.backend.base.Backend :members: - In the following, the words REQUIRED, OPTIONAL, etc. have specific meanings as described in `IETF RFC 2119 `_. + In the following, the bold-face words **required**, **optional**, etc. have specific meanings as described in `IETF RFC 2119 `_. - Backend is an **abstract** class; this means it MUST be subclassed. - Most of its methods are decorated with :meth:`abc.abstractmethod`; this means they are REQUIRED and MUST be overridden by subclasses. + Backend is an **abstract** class; this means it **must** be subclassed. + Most of its methods are decorated with :meth:`abc.abstractmethod`; this means they are **required** and **must** be overridden by subclasses. - Others, marked below with “(OPTIONAL)”, are not so decorated. + Others, marked below with "OPTIONAL:", are not so decorated. For these methods, the behaviour in the base Backend—often, nothing—is an acceptable default behaviour. - Subclasses MAY extend or replace this behaviour as desired, so long as the methods still perform the actions described in the description. + Subclasses **may** extend or replace this behaviour as desired, so long as the methods still perform the actions described in the description. + + Backends: + + - **must** only raise standard Python exceptions. Methods related to :class:`ixmp.Platform`: @@ -96,13 +100,14 @@ Backend API ts_delete_geo ts_discard_changes ts_get + ts_get_data ts_get_geo ts_init ts_is_default ts_last_update ts_preload ts_run_id - ts_set + ts_set_data ts_set_as_default ts_set_geo @@ -117,6 +122,7 @@ Backend API s_add_set_elements s_clone s_delete_item + s_get s_get_meta s_has_solution s_init @@ -137,5 +143,4 @@ Backend API ms_cat_get_elements ms_cat_list ms_cat_set_elements - ms_year_first_model ms_years_active diff --git a/doc/source/conf.py b/doc/source/conf.py index ff5728af2..6c92cc201 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -35,14 +35,14 @@ sys.path.append(os.path.abspath('exts')) extensions = [ 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.coverage', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', 'sphinx.ext.mathjax', + 'sphinx.ext.napoleon', + 'sphinx.ext.todo', 'sphinxcontrib.bibtex', - 'sphinx.ext.autosummary', - 'sphinx.ext.napoleon' ] # Add any paths that contain templates here, relative to this directory. diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index f2fb10621..b948d4926 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -22,7 +22,7 @@ class Backend(ABC): # NOT override the non-abstract methods; then call those. def __init__(self): # pragma: no cover - """Initialize the backend.""" + """OPTIONAL: Initialize the backend.""" pass def close_db(self): # pragma: no cover @@ -35,7 +35,11 @@ def close_db(self): # pragma: no cover def get_auth(self, user, models, kind): # pragma: no cover """OPTIONAL: Return user authorization for *models*. - If the Backend implements access control, + If the Backend implements access control, this method **must** indicate + whether *user* has permission *kind* for each of *models*. + + *kind* **may** be 'read'/'view', 'write'/'modify', or other values; + :meth:`get_auth` **should** raise exceptions on invalid values. Parameters ---------- @@ -49,8 +53,8 @@ def get_auth(self, user, models, kind): # pragma: no cover Returns ------- dict - Mapping of `model name (str)` → `bool`; :obj:`True` if the user is - authorized for the model. + Mapping of *model name (str)* → :class:`bool`; :obj:`True` if the + user is authorized for the model. """ return {model: True for model in models} @@ -71,6 +75,10 @@ def get_nodes(self): parent str Parent node name hierarchy str Node hierarchy ID ========= =========== === + + See also + -------- + set_node """ @abstractmethod @@ -96,35 +104,39 @@ def get_scenarios(self, default, model, scenario): ========== ==== === ID Type Description ========== ==== === - model str Model name. - scenario str Scenario name. - scheme str Scheme name. - is_default bool :obj:`True` if `version` is the default. - is_locked bool :obj:`True` if read-only. - cre_user str Name of user who created the TimeSeries. - cre_date str Creation datetime. - upd_user str Name of user who last modified the TimeSeries. - upd_date str Modification datetime. - lock_user str Name of user who locked the TimeSeries. - lock_date str Lock datetime. - annotation str Description of the TimeSeries. - version int Version. + model str Model name + scenario str Scenario name + scheme str Scheme name + is_default bool :obj:`True` if `version` is the default + is_locked bool :obj:`True` if read-only + cre_user str Name of user who created the TimeSeries + cre_date str Creation datetime + upd_user str Name of user who last modified the TimeSeries + upd_date str Modification datetime + lock_user str Name of user who locked the TimeSeries + lock_date str Lock datetime + annotation str Description of the TimeSeries + version int Version ========== ==== === """ @abstractmethod def get_units(self): - """Return all registered units of measurement. + """Return all registered symbols for units of measurement. Returns ------- list of str + + See also + -------- + set_unit """ def open_db(self): # pragma: no cover """OPTIONAL: (Re-)open database connection(s). - A backend MAY connect to a database server. This method opens the + A backend **may** connect to a database server. This method opens the database connection if it is closed. """ pass @@ -142,7 +154,8 @@ def set_log_level(self, level): # pragma: no cover def set_node(self, name, parent=None, hierarchy=None, synonym=None): """Add a node name to the Platform. - This method MUST be callable in one of two ways: + This method **must** have one of two effects, depending on the + arguments: - With `parent` and `hierarchy`: `name` is added as a child of `parent` in the named `hierarchy`. @@ -158,6 +171,10 @@ def set_node(self, name, parent=None, hierarchy=None, synonym=None): Node hierarchy ID. synonym : str, optional Synonym for node. + + See also + -------- + get_nodes """ @abstractmethod @@ -169,7 +186,11 @@ def set_unit(self, name, comment): name : str Symbol of the unit. comment : str - Description of the change. + Description of the change or of the unit. + + See also + -------- + get_units """ # Methods for ixmp.TimeSeries @@ -178,51 +199,102 @@ def set_unit(self, name, comment): def ts_init(self, ts, annotation=None): """Initialize the TimeSeries *ts*. - The method MAY: + ts_init **may** modify the :attr:`~TimeSeries.version` attribute of + *ts*. - - Modify the version attr of the returned object. + Parameters + ---------- + annotation : str + If *ts* is newly-created, the Backend **must** store this + annotation with the TimeSeries. + + Returns + ------- + None + """ + + @abstractmethod + def ts_get(self, ts, version): + """Retrieve the existing TimeSeries *ts*. + + The TimeSeries is identified based on its (:attr:`~.TimeSeries.model`, + :attr:`~.TimeSeries.scenario`) and *version*. + + Parameters + ---------- + version : str or None + If :obj:`None`, the version marked as the default is returned, and + ts_get **must** set :attr:`.TimeSeries.version` attribute on *ts*. + + Returns + ------- + None + + See also + -------- + ts_set_as_default """ @abstractmethod def ts_check_out(self, ts, timeseries_only): - """Check out *ts* for modifications. + """Check out *ts* for modification. Parameters ---------- timeseries_only : bool ??? + + Returns + ------- + None """ @abstractmethod def ts_commit(self, ts, comment): """Commit changes to *ts*. - The method MAY: + ts_init **may** modify the :attr:`~.TimeSeries.version` attribute of + *ts*. + + Parameters + ---------- + comment : str + Description of the changes being committed. - - Modify the version attr of *ts*. + Returns + ------- + None """ @abstractmethod - def ts_get(self, ts, region, variable, unit, year): + def ts_get_data(self, ts, region, variable, unit, year): """Retrieve time-series data. Parameters ---------- region : list of str + Region names to filter results. variable : list of str + Variable names to filter results. unit : list of str + Unit symbols to filter results. year : list of str + Years to filter results. Yields ------ tuple - The five members of each tuple are: + The members of each tuple are: - 1. region: str. - 2. variable: str. - 3. unit: str. - 4. year: int. - 5. value: float. + ======== ===== === + ID Type Description + ======== ===== === + region str Region name + variable str Variable name + unit str Unit symbol + year int Year + value float Data value + ======== ===== === """ @abstractmethod @@ -232,29 +304,39 @@ def ts_get_geo(self, ts): Yields ------ tuple - The seven members of each tuple are: + The members of each tuple are: - 1. region: str. - 2. variable: str. - 3. time: str. - 4. year: int. - 5. value: str. - 6. unit: str. - 7. meta: int. + ======== ==== === + ID Type Description + ======== ==== === + region str Region name + variable str Variable name + time str Time period + year int Year + value str Value + unit str Unit symbol + meta bool :obj:`True` if the data is marked as metadata + ======== ==== === """ @abstractmethod - def ts_set(self, ts, region, variable, data, unit, meta): + def ts_set_data(self, ts, region, variable, data, unit, meta): """Store *data* in *ts*. Parameters ---------- - region, variable, time, unit : str - Indices for the data. + region : str + Region name. + variable : str + Variable name. + time : str + Time period. + unit : str + Unit symbol. data : dict (int -> float) Mapping from year to value. meta : bool - Metadata flag. + :obj:`True` to mark *data* as metadata. """ @abstractmethod @@ -263,31 +345,87 @@ def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): Parameters ---------- - region, variable, time, unit : str - Indices for the data. + region : str + Region name. + variable : str + Variable name. + time : str + Time period. year : int - Year index. + Year. value : str - Data. + Data value. + unit : str + Unit symbol. meta : bool - Metadata flag. + :obj:`True` to mark *data* as metadata. """ @abstractmethod def ts_delete(self, ts, region, variable, years, unit): - """Remove data values from *ts*.""" + """Remove data values from *ts*. + + Parameters + ---------- + region : str + Region name. + variable : str + Variable name. + years : Iterable of int + Years. + unit : str + Unit symbol. + + Returns + ------- + None + """ @abstractmethod def ts_delete_geo(self, ts, region, variable, time, years, unit): - """Remove 'geodata' values from *ts*.""" + """Remove 'geodata' values from *ts*. + + Parameters + ---------- + region : str + Region name. + variable : str + Variable name. + time : str + Time period. + years : Iterable of int + Years. + unit : str + Unit symbol. + + Returns + ------- + None + """ @abstractmethod def ts_discard_changes(self, ts): - """Discard changes to *ts* since the last commit.""" + """Discard changes to *ts* since the last :meth:`ts_check_out`. + + Returns + ------- + None + """ @abstractmethod def ts_set_as_default(self, ts): - """Set *ts* as the default version.""" + """Set the current :attr:`.TimeSeries.version` as the default. + + Returns + ------- + None + + See also + -------- + ts_is_default + ts_get + s_get + """ @abstractmethod def ts_is_default(self, ts): @@ -296,15 +434,31 @@ def ts_is_default(self, ts): Returns ------- bool + + See also + -------- + ts_set_as_default + ts_get + s_get """ @abstractmethod def ts_last_update(self, ts): - """Return the date of the last modification of the *ts*.""" + """Return the date of the last modification of the *ts*. + + Returns + ------- + str + """ @abstractmethod def ts_run_id(self, ts): - """Return the run ID for the *ts*.""" + """Return the run ID for the *ts*. + + Returns + ------- + int + """ def ts_preload(self, ts): # pragma: no cover """OPTIONAL: Load *ts* data into memory.""" @@ -313,48 +467,123 @@ def ts_preload(self, ts): # pragma: no cover # Methods for ixmp.Scenario @abstractmethod - def s_clone(self, s, target_backend, model, scenario, annotation, + def s_init(self, s, scheme, annotation): + """Initialize the Scenario *s*. + + s_init **may** modify the :attr:`~.TimeSeries.version` attribute of + *s*. + + Parameters + ---------- + scheme : str + Description of the scheme of the Scenario. + annotation : str + Description of the Scenario. + + Returns + ------- + None + """ + + @abstractmethod + def s_get(self, s, version): + """Retrieve the existing Scenario *ts*. + + The Scenario is identified based on its (:attr:`~.TimeSeries.model`, + :attr:`~.TimeSeries.scenario`) and *version*. s_get **must** set + the :attr:`.Scenario.scheme` attribute on *s*. + + Parameters + ---------- + version : str or None + If :obj:`None`, the version marked as the default is returned, and + s_get **must** set :attr:`.TimeSeries.version` attribute on *s*. + + Returns + ------- + None + + See also + -------- + ts_set_as_default + """ + + @abstractmethod + def s_clone(self, s, platform_dest, model, scenario, annotation, keep_solution, first_model_year=None): """Clone *s*. Parameters ---------- - target_backend : :class:`ixmp.backend.base.Backend` - Target backend. May be the same as `s.platform._backend`. + platform_dest : :class:`.Platform` + Target backend. May be the same as *s.platform*. model : str + New model name. scenario : str + New scenario name. annotation : str + Description for the creation of the new scenario. keep_solution : bool + If :obj:`True`, model solution data is also cloned. If + :obj:`False`, it is discarded. first_model_year : int or None - """ - - @abstractmethod - def s_init(self, s, annotation=None): - """Initialize the Scenario *s*. + If :class:`int`, must be greater than the first model year of *s*. - The method MAY: - - - Modify the version attr of the returned object. + Returns + ------- + Same class as *s* + The cloned Scenario. """ @abstractmethod def s_has_solution(self, s): - """Return :obj:`True` if Scenario *s* has been solved. + """Return `True` if Scenario *s* has been solved. If :obj:`True`, model solution data is available from the Backend. """ @abstractmethod def s_list_items(self, s, type): - """Return a list of items of *type* in Scenario *s*.""" + """Return a list of items of *type* in Scenario *s*. + + Parameters + ---------- + type : 'set' or 'par' or 'equ' + + Return + ------ + list of str + """ @abstractmethod def s_init_item(self, s, type, name): - """Initialize an item *name* of *type* in Scenario *s*.""" + """Initialize an item *name* of *type* in Scenario *s*. + + Parameters + ---------- + type : 'set' or 'par' or 'equ' + name : str + Name for the new item. + + Return + ------ + None + """ @abstractmethod def s_delete_item(self, s, type, name): - """Remove an item *name* of *type* in Scenario *s*.""" + """Remove an item *name* of *type* in Scenario *s*. + + Parameters + ---------- + type : 'set' or 'par' or 'equ' + name : str + Name of the item to delete. + + Return + ------ + None + """ @abstractmethod def s_item_index(self, s, name, sets_or_names): @@ -363,6 +592,10 @@ def s_item_index(self, s, name, sets_or_names): Parameters ---------- sets_or_names : 'sets' or 'names' + + Returns + ------- + list of str """ @abstractmethod @@ -376,14 +609,16 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, - Lists, e.g. set elements. - Mapping sets. - Multi-dimensional parameters, equations, or variables. + + .. todo:: Exactly specify the return types. """ - # TODO exactly specify the return types in the docstring using MUST, - # MAY, etc. terms @abstractmethod def s_add_set_elements(self, s, name, elements): """Add elements to set *name* in Scenario *s*. + .. todo:: Use a table for *elements*. + Parameters ---------- elements : iterable of 2-tuples @@ -406,6 +641,8 @@ def s_add_set_elements(self, s, name, elements): def s_add_par_values(self, s, name, elements): """Add values to parameter *name* in Scenario *s*. + .. todo:: Use a table for *elements*. Rename to s_set_data_par. + Parameters ---------- elements : iterable of 4-tuples @@ -428,38 +665,92 @@ def s_add_par_values(self, s, name, elements): @abstractmethod def s_item_delete_elements(self, s, type, name, key): - """Remove elements of item *name*.""" + """Remove elements of item *name*. + + .. todo:: Document. + """ @abstractmethod def s_get_meta(self, s): - """Return all metadata.""" + """Return all metadata. + + Returns + ------- + dict (str -> any) + Mapping from metadata keys to values. + + See also + -------- + s_get_meta + """ @abstractmethod def s_set_meta(self, s, name, value): - """Set a single metadata key.""" + """Set a single metadata key. + + Parameters + ---------- + name : str + Metadata key name. + value : any + Value for *name*. + + Returns + ------- + None + """ @abstractmethod def s_clear_solution(self, s, from_year=None): - """Remove data associated with a model solution.""" + """Remove data associated with a model solution. + + .. todo:: Document. + """ # Methods for message_ix.Scenario @abstractmethod def ms_cat_list(self, ms, name): - """Return list of categories.""" + """Return list of categories in mapping *name*. + + Parameters + ---------- + name : str + Name of the category mapping set. + + Returns + ------- + list of str + All categories in *name*. + """ @abstractmethod def ms_cat_get_elements(self, ms, name, cat): - """Get elements of a category mapping.""" + """Get elements of a category mapping. + + Parameters + ---------- + name : str + Name of the category mapping set. + cat : str + Name of the category within *name*. + + Returns + ------- + list of str + All set elements mapped to *cat* in *name*. + """ @abstractmethod def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): - """Add elements to category mapping.""" + """Add elements to category mapping. - @abstractmethod - def ms_year_first_model(self, ms): - """Return the first model year.""" + .. todo:: Document. + """ @abstractmethod def ms_years_active(self, ms, node, tec, year_vintage): - """Return a list of years in which *tec* is active.""" + """Return a list of years in which *tec* is active. + + .. todo:: Document. + """ diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index bea1fc55b..814c1571a 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -57,8 +57,7 @@ class JDBCBackend(Backend): dbprops : path-like, optional If `dbtype` is :obj:`None`, the name of a *database properties file* (default: ``default.properties``) in the properties file directory - (see :class:`Config `) or a path to a properties - file. + (see :class:`.Config`) or a path to a properties file. If `dbtype` is 'HSQLDB'`, the path of a local database, (default: ``$HOME/.local/ixmp/localdb/default``) or name of a @@ -66,8 +65,7 @@ class JDBCBackend(Backend): ``$HOME/.local/ixmp/localdb/``). jvmargs : str, optional - Java Virtual Machine arguments. - See :meth:`ixmp.backend.jdbc.start_jvm`. + Java Virtual Machine arguments. See :func:`.start_jvm`. """ # NB Much of the code of this backend is in Java, in the iiasa/ixmp_source # Github repository. @@ -170,22 +168,41 @@ def get_units(self): # Timeseries methods + def _common_init(self, ts, klass, *args): + """Common code for ts_init and s_init.""" + method = getattr(self.jobj, 'new' + klass) + # Create a new TimeSeries + jobj = method(ts.model, ts.scenario, *args) + + # Add to index + self.jindex[ts] = jobj + + # Retrieve initial version + ts.version = jobj.getVersion() + def ts_init(self, ts, annotation=None): - if ts.version == 'new': - # Create a new TimeSeries - jobj = self.jobj.newTimeSeries(ts.model, ts.scenario, annotation) - elif isinstance(ts.version, int): + self._common_init(ts, 'TimeSeries', annotation) + + def _common_get(self, ts, klass, version): + """Common code for ts_get and s_get.""" + args = [ts.model, ts.scenario] + if isinstance(version, int): # Load a TimeSeries of specific version - jobj = self.jobj.getTimeSeries(ts.model, ts.scenario, ts.version) - else: - # Load the latest version of a TimeSeries - jobj = self.jobj.getTimeSeries(ts.model, ts.scenario) + args.append(version) + + method = getattr(self.jobj, 'get' + klass) + jobj = method(*args) + # Add to index + self.jindex[ts] = jobj + if version is None: # Update the version attribute ts.version = jobj.getVersion() + else: + assert version == jobj.getVersion() - # Add to index - self.jindex[ts] = jobj + def ts_get(self, ts, version): + self._common_get(ts, 'TimeSeries', version) def ts_check_out(self, ts, timeseries_only): self.jindex[ts].checkOut(timeseries_only) @@ -213,7 +230,7 @@ def ts_run_id(self, ts): def ts_preload(self, ts): self.jindex[ts].preloadAllTimeseries() - def ts_get(self, ts, region, variable, unit, year): + def ts_get_data(self, ts, region, variable, unit, year): # Convert the selectors to Java lists r = to_jlist2(region) v = to_jlist2(variable) @@ -274,7 +291,7 @@ def ts_get_geo(self, ts): # Construct a row with a single value yield tuple(cm[f] for f in FIELDS['ts_get_geo']) - def ts_set(self, ts, region, variable, data, unit, meta): + def ts_set_data(self, ts, region, variable, data, unit, meta): # Convert *data* to a Java data structure jdata = java.LinkedHashMap() for k, v in data.items(): @@ -298,33 +315,21 @@ def ts_delete_geo(self, ts, region, variable, time, years, unit): # Scenario methods - def s_init(self, s, scheme=None, annotation=None): - if s.version == 'new': - jobj = self.jobj.newScenario(s.model, s.scenario, scheme, - annotation) - elif isinstance(s.version, int): - jobj = self.jobj.getScenario(s.model, s.scenario, s.version) - # constructor for `message_ix.Scenario.__init__` or `clone()` function - elif isinstance(s.version, java.Scenario): - jobj = s.version - elif s.version is None: - jobj = self.jobj.getScenario(s.model, s.scenario) - else: - raise ValueError('Invalid `version` arg: `{}`'.format(s.version)) + def s_init(self, s, scheme, annotation): + self._common_init(s, 'Scenario', scheme, annotation) - s.version = jobj.getVersion() - s.scheme = jobj.getScheme() + def s_get(self, s, version): + self._common_get(s, 'Scenario', version) + # Also retrieve the scheme + s.scheme = self.jindex[s].getScheme() - # Add to index - self.jindex[s] = jobj - - def s_clone(self, s, target_backend, model, scenario, annotation, + def s_clone(self, s, platform_dest, model, scenario, annotation, keep_solution, first_model_year=None): # Raise exceptions for limitations of JDBCBackend - if not isinstance(target_backend, self.__class__): + if not isinstance(platform_dest._backend, self.__class__): raise NotImplementedError(f'Clone between {self.__class__} and' - f'{target_backend.__class__}') - elif target_backend is not self: + f'{platform_dest._backend.__class__}') + elif platform_dest._backend is not self: msg = 'Cross-platform clone of {}.Scenario with'.format( s.__class__.__module__.split('.')[0]) if keep_solution is False: @@ -332,12 +337,18 @@ def s_clone(self, s, target_backend, model, scenario, annotation, elif 'message_ix' in msg and first_model_year is not None: raise NotImplementedError(msg + ' first_model_year != None') - args = [model, scenario, annotation, keep_solution] + # Prepare arguments + args = [platform_dest._backend.jobj, model, scenario, annotation, + keep_solution] if first_model_year: args.append(first_model_year) # Reference to the cloned Java object - return self.jindex[s].clone(target_backend.jobj, *args) + jclone = self.jindex[s].clone(*args) + + # Instantiate same class as the original object + return s.__class__(platform_dest, model, scenario, + version=jclone.getVersion(), cache=s._cache) def s_has_solution(self, s): return self.jindex[s].hasSolution() @@ -503,9 +514,6 @@ def ms_cat_get_elements(self, ms, name, cat): def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): self.jindex[ms].addCatEle(name, cat, to_jlist2(keys), is_unique) - def ms_year_first_model(self, ms): - return self.jindex[ms].getFirstModelYear() - def ms_years_active(self, ms, node, tec, year_vintage): return list(self.jindex[ms].getTecActYrs(node, tec, year_vintage)) @@ -566,7 +574,7 @@ def start_jvm(jvmargs=None): ---------- jvmargs : str or list of str, optional Additional arguments for launching the JVM, passed to - :meth:`jpype.startJVM`. + :func:`jpype.startJVM`. For instance, to set the maximum heap space to 4 GiB, give ``jvmargs=['-Xmx4G']``. See the `JVM documentation`_ for a list of diff --git a/ixmp/core.py b/ixmp/core.py index 226450c42..8811e0264 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -37,12 +37,10 @@ class Platform: ---------- backend : 'jdbc' Storage backend type. 'jdbc' corresponds to the built-in - :class:`JDBCBackend `; see - :obj:`ixmp.backend.BACKENDS`. + :class:`.JDBCBackend`; see :obj:`.BACKENDS`. backend_args - Keyword arguments to specific to the `backend`. - The “Other Parameters” shown below are specific to - :class:`JDBCBackend `. + Keyword arguments to specific to the `backend`. The “Other Parameters” + shown below are specific to :class:`.JDBCBackend`. Other parameters ---------------- @@ -54,8 +52,7 @@ class Platform: dbprops : path-like, optional If `dbtype` is :obj:`None`, the name of a *database properties file* (default: ``default.properties``) in the properties file directory - (see :class:`Config `) or a path to a properties - file. + (see :class:`.Config`) or a path to a properties file. If `dbtype` is 'HSQLDB'`, the path of a local database, (default: ``$HOME/.local/ixmp/localdb/default``) or name of a @@ -63,8 +60,7 @@ class Platform: ``$HOME/.local/ixmp/localdb/``). jvmargs : str, optional - Java Virtual Machine arguments. - See :meth:`ixmp.backend.jdbc.start_jvm`. + Java Virtual Machine arguments. See :func:`.start_jvm`. """ # List of method names which are handled directly by the backend @@ -300,20 +296,22 @@ class TimeSeries: #: Version of the TimeSeries. Immutable for a specific instance. version = None - _timespans = {} def __init__(self, mp, model, scenario, version=None, annotation=None): if not isinstance(mp, Platform): raise ValueError('mp is not a valid `ixmp.Platform` instance') # Set attributes + self.platform = mp self.model = model self.scenario = scenario - self.version = version - # All the backend to complete initialization - self.platform = mp - self._backend('init', annotation) + if version == 'new': + self._backend('init', annotation) + elif isinstance(version, int) or version is None: + self._backend('get', version) + else: + raise ValueError(f'version={version!r}') def _backend(self, method, *args, **kwargs): """Convenience for calling *method* on the backend.""" @@ -323,7 +321,7 @@ def _backend(self, method, *args, **kwargs): # functions for platform management def check_out(self, timeseries_only=False): - """check out from the ixmp database instance for making changes""" + """Check out the TimeSeries for modification.""" if not timeseries_only and self.has_solution(): raise ValueError('This Scenario has a solution, ' 'use `Scenario.remove_solution()` or ' @@ -419,7 +417,8 @@ def add_timeseries(self, df, meta=False): # Add one time series per row for (r, v, u), data in df.iterrows(): # Values as float; exclude NA - self._backend('set', r, v, data.astype(float).dropna(), u, meta) + self._backend('set_data', r, v, data.astype(float).dropna(), u, + meta) def timeseries(self, region=None, variable=None, unit=None, year=None, iamc=False): @@ -445,7 +444,7 @@ def timeseries(self, region=None, variable=None, unit=None, year=None, Specified data. """ # Retrieve data, convert to pandas.DataFrame - df = pd.DataFrame(self._backend('get', + df = pd.DataFrame(self._backend('get_data', as_str_list(region) or [], as_str_list(variable) or [], as_str_list(unit) or [], @@ -561,19 +560,25 @@ class Scenario(TimeSeries): 'equ': {'has_level': True}, } + #: Scheme of the Scenario. + scheme = None + def __init__(self, mp, model, scenario, version=None, scheme=None, annotation=None, cache=False): if not isinstance(mp, Platform): raise ValueError('mp is not a valid `ixmp.Platform` instance') # Set attributes + self.platform = mp self.model = model self.scenario = scenario - self.version = version - # All the backend to complete initialization - self.platform = mp - self._backend('init', scheme, annotation) + if version == 'new': + self._backend('init', scheme, annotation) + elif isinstance(version, int) or version is None: + self._backend('get', version) + else: + raise ValueError(f'version={version!r}') if self.scheme == 'MESSAGE' and not hasattr(self, 'is_message_scheme'): warn('Using `ixmp.Scenario` for MESSAGE-scheme scenarios is ' @@ -1161,19 +1166,11 @@ def clone(self, model=None, scenario=None, annotation=None, model = model or self.model scenario = scenario or self.scenario - args = [platform._backend, model, scenario, annotation, keep_solution] + args = [platform, model, scenario, annotation, keep_solution] if check_year(shift_first_model_year, 'first_model_year'): args.append(shift_first_model_year) - scenario_class = self.__class__ - - # NB cloning happens entirely within the Java code. This requires - # 'jclone', a reference to a Java object, to be returned here... - jclone = self._backend('clone', *args) - # ...and passed in to the constructor here. To avoid this, would - # need to adjust the ixmp_source code. - return scenario_class(platform, model, scenario, cache=self._cache, - version=jclone) + return self._backend('clone', *args) def has_solution(self): """Return :obj:`True` if the Scenario has been solved. @@ -1210,12 +1207,10 @@ def remove_solution(self, first_model_year=None): def solve(self, model=None, callback=None, cb_kwargs={}, **model_options): """Solve the model and store output. - ixmp 'solves' a model by invoking the run() method of a - :class:`Model ` subclass—for instance, - :meth:`GAMSModel.run `. - Depending on the underlying model code, different steps are taken; see - each model class for details. - In general: + ixmp 'solves' a model by invoking the run() method of a :class:`.Model` + subclass—for instance, :meth:`.GAMSModel.run`. Depending on the + underlying model code, different steps are taken; see each model class + for details. In general: 1. Data from the Scenario are written to a **model input file**. 2. Code or an external program is invoked to perform calculations or @@ -1243,8 +1238,7 @@ def solve(self, model=None, callback=None, cb_kwargs={}, **model_options): cb_kwargs : dict, optional Keyword arguments to pass to `callback`. model_options : - Keyword arguments specific to the `model`. See - :class:`GAMSModel `. + Keyword arguments specific to the `model`. See :class:`.GAMSModel`. Warns ----- diff --git a/ixmp/model/base.py b/ixmp/model/base.py index 3bb08635c..b93518822 100644 --- a/ixmp/model/base.py +++ b/ixmp/model/base.py @@ -12,7 +12,7 @@ def __init__(self, name, **kwargs): Parameters ---------- kwargs : - Model options, passed directly from :meth:`ixmp.Scenario.solve`. + Model options, passed directly from :meth:`.Scenario.solve`. Model subclasses MUST document acceptable option values. """ @@ -24,7 +24,7 @@ def run(self, scenario): Parameters ---------- - scenario : ixmp.Scenario + scenario : .Scenario Scenario object to solve by running the Model. """ pass diff --git a/ixmp/model/gams.py b/ixmp/model/gams.py index 3762decc2..8ade3abc5 100644 --- a/ixmp/model/gams.py +++ b/ixmp/model/gams.py @@ -34,7 +34,7 @@ class GAMSModel(Model): case : str, optional Run or case identifier to use in GDX file names. Default: ``'{scenario.model}_{scenario.name}'``, where `scenario` is the - :class:`ixmp.Scenario` object passed to :meth:`run`. + :class:`.Scenario` object passed to :meth:`run`. Formatted using `model_name` and `scenario`. in_file : str, optional Path to write GDX input file. Default: ``'{model_name}_in.gdx'``. From 6bcaccf4d4a3c6b169a395dcce23800853f08886 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 00:04:06 +0200 Subject: [PATCH 57/71] Update RELEASE_NOTES --- RELEASE_NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 785003744..7eaee7422 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,7 @@ # Next Release +- [#182](https://github.com/iiasa/ixmp/pull/182): Add new Backend, Model APIs and JDBCBackend, GAMSModel classes. - [#188](https://github.com/iiasa/ixmp/pull/188): Enhance reporting. - [#177](https://github.com/iiasa/ixmp/pull/177): add ability to pass `gams_args` through `Scenario.solve()` - [#175](https://github.com/iiasa/ixmp/pull/175): Drop support for Python 2. From 84158cc0be6ac914202004057d6eb7347df77cca Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 14:16:44 +0100 Subject: [PATCH 58/71] Remove _backend method for TimeSeries subclasses --- ixmp/backend/jdbc.py | 5 ++--- ixmp/core.py | 39 +++++++++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 814c1571a..f02d2c24f 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -463,10 +463,9 @@ def s_add_par_values(self, s, name, elements): jPar = self._get_item(s, 'par', name) for key, value, unit, comment in elements: - args = [] + args = [java.Double(value), unit] if key: - args.append(to_jlist2(key)) - args.extend([java.Double(value), unit]) + args.insert(0, to_jlist2(key)) if comment: args.append(comment) diff --git a/ixmp/core.py b/ixmp/core.py index 8811e0264..86b95eab1 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,4 +1,5 @@ # coding=utf-8 +from functools import lru_cache from itertools import repeat, zip_longest import logging from warnings import warn @@ -313,10 +314,33 @@ def __init__(self, mp, model, scenario, version=None, annotation=None): else: raise ValueError(f'version={version!r}') + # Name prefix for Backend methods called through ._backend() + _backend_prefix = 'ts' + + @classmethod + @lru_cache(1) + def __prefixes(cls): + """List of prefixes to try when calling backend methods. + + For TimeSeries subclasses, returns the _backend_prefix attribute for + the class itself, then each parent class, up to TimeSeries itself. + """ + return tuple(getattr(c, '_backend_prefix') for c in cls.__mro__[:-1]) + def _backend(self, method, *args, **kwargs): """Convenience for calling *method* on the backend.""" - func = getattr(self.platform._backend, f'ts_{method}') - return func(self, *args, **kwargs) + # Try each prefix, e.g. 's_' and then others + for prefix in self.__class__.__prefixes(): + try: + func = getattr(self.platform._backend, f'{prefix}_{method}') + except AttributeError: + # Not an existing backend method + continue + else: + # Located + return func(self, *args, **kwargs) + + raise ValueError(f'backend method {method!r}') # functions for platform management @@ -553,6 +577,9 @@ class Scenario(TimeSeries): Store data in memory and return cached values instead of repeatedly querying the backend. """ + # Name prefix for Backend methods called through ._backend() + _backend_prefix = 's' + _java_kwargs = { 'set': {}, 'par': {'has_value': True}, @@ -588,14 +615,6 @@ def __init__(self, mp, model, scenario, version=None, scheme=None, self._cache = cache self._pycache = {} - def _backend(self, method, *args, **kwargs): - """Convenience for calling *method* on the backend.""" - try: - func = getattr(self.platform._backend, f's_{method}') - except AttributeError: - func = getattr(self.platform._backend, f'ts_{method}') - return func(self, *args, **kwargs) - def load_scenario_data(self): """Load all Scenario data into memory. From d2a987bf620aa486695db201a3c4dfed1c231e61 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 14:50:29 +0100 Subject: [PATCH 59/71] Move call conveniences into base.Backend --- ixmp/backend/base.py | 20 ++++++++++++++++++++ ixmp/core.py | 30 ++++-------------------------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index b948d4926..0f3a9ce37 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -1,3 +1,4 @@ +from functools import lru_cache from abc import ABC, abstractmethod @@ -25,6 +26,25 @@ def __init__(self): # pragma: no cover """OPTIONAL: Initialize the backend.""" pass + @classmethod + @lru_cache() # Don't recompute + def __method(backend_cls, cls, name): + for c in cls.__mro__[:-1]: + try: + return getattr(backend_cls, f'{c._backend_prefix}_{name}') + except AttributeError: + pass + raise AttributeError(f"backend method '{{prefix}}_{name}'") + + def __call__(self, obj, method, *args, **kwargs): + """Call the backend method *method* for *obj*. + + The class attribute obj._backend_prefix is used to determine a prefix + for the method name, e.g. 'ts_{method}'. + """ + method = self.__method(obj.__class__, method) + return method(self, obj, *args, **kwargs) + def close_db(self): # pragma: no cover """OPTIONAL: Close database connection(s). diff --git a/ixmp/core.py b/ixmp/core.py index 86b95eab1..52f566d85 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,5 +1,4 @@ # coding=utf-8 -from functools import lru_cache from itertools import repeat, zip_longest import logging from warnings import warn @@ -289,6 +288,9 @@ class TimeSeries: annotation : str, optional A short annotation/comment used when ``version='new'``. """ + # Name prefix for Backend methods called through ._backend() + _backend_prefix = 'ts' + #: Name of the model associated with the TimeSeries model = None @@ -314,33 +316,9 @@ def __init__(self, mp, model, scenario, version=None, annotation=None): else: raise ValueError(f'version={version!r}') - # Name prefix for Backend methods called through ._backend() - _backend_prefix = 'ts' - - @classmethod - @lru_cache(1) - def __prefixes(cls): - """List of prefixes to try when calling backend methods. - - For TimeSeries subclasses, returns the _backend_prefix attribute for - the class itself, then each parent class, up to TimeSeries itself. - """ - return tuple(getattr(c, '_backend_prefix') for c in cls.__mro__[:-1]) - def _backend(self, method, *args, **kwargs): """Convenience for calling *method* on the backend.""" - # Try each prefix, e.g. 's_' and then others - for prefix in self.__class__.__prefixes(): - try: - func = getattr(self.platform._backend, f'{prefix}_{method}') - except AttributeError: - # Not an existing backend method - continue - else: - # Located - return func(self, *args, **kwargs) - - raise ValueError(f'backend method {method!r}') + return self.platform._backend(self, method, *args, **kwargs) # functions for platform management From 3c37020bd178779705e7d4a610fca938b6d75c90 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 14:59:53 +0100 Subject: [PATCH 60/71] Remove Scenario._java_kwargs --- ixmp/backend/base.py | 3 +-- ixmp/backend/jdbc.py | 17 ++++++++--------- ixmp/core.py | 18 ++++-------------- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 0f3a9ce37..4b6168a68 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -619,8 +619,7 @@ def s_item_index(self, s, name, sets_or_names): """ @abstractmethod - def s_item_elements(self, s, type, name, filters=None, has_value=False, - has_level=False): + def s_item_elements(self, s, type, name, filters=None): """Return elements of item *name* in Scenario *s*. The return type varies according to the *type* and contents: diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index f02d2c24f..42597958e 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -377,16 +377,15 @@ def s_item_index(self, s, name, sets_or_names): jitem = self._get_item(s, 'item', name, load=False) return list(getattr(jitem, f'getIdx{sets_or_names.title()}')()) - def s_item_elements(self, s, type, name, filters=None, has_value=False, - has_level=False): + def s_item_elements(self, s, type, name, filters=None): # Retrieve the item item = self._get_item(s, type, name, load=True) # get list of elements, with filter HashMap if provided if filters is not None: jFilter = java.HashMap() - for idx_name in filters.keys(): - jFilter.put(idx_name, to_jlist(filters[idx_name])) + for idx_name, values in filters.items(): + jFilter.put(idx_name, to_jlist(values)) jList = item.getElements(jFilter) else: jList = item.getElements() @@ -408,11 +407,11 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, ary = ary.astype('int') data[idx_names[d]] = ary - if has_value: + if type == 'par': data['value'] = np.array(item.getValues(jList)[:]) data['unit'] = np.array(item.getUnits(jList)[:]) - if has_level: + if type in ('equ', 'var'): data['lvl'] = np.array(item.getLevels(jList)[:]) data['mrg'] = np.array(item.getMarginals(jList)[:]) @@ -421,18 +420,18 @@ def s_item_elements(self, s, type, name, filters=None, has_value=False, else: # for index sets - if not (has_value or has_level): + if type == 'set': return pd.Series(item.getCol(0, jList)[:]) data = {} # for parameters as scalars - if has_value: + if type == 'par': data['value'] = item.getScalarValue().floatValue() data['unit'] = str(item.getScalarUnit()) # for variables as scalars - elif has_level: + elif type in ('equ', 'var'): data['lvl'] = item.getScalarLevel().floatValue() data['mrg'] = item.getScalarMarginal().floatValue() diff --git a/ixmp/core.py b/ixmp/core.py index 52f566d85..f5681a41a 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -558,13 +558,6 @@ class Scenario(TimeSeries): # Name prefix for Backend methods called through ._backend() _backend_prefix = 's' - _java_kwargs = { - 'set': {}, - 'par': {'has_value': True}, - 'var': {'has_level': True}, - 'equ': {'has_level': True}, - } - #: Scheme of the Scenario. scheme = None @@ -623,14 +616,12 @@ def _element(self, ix_type, name, filters=None, cache=None): if cache_key in self._pycache: return filtered(self._pycache[cache_key], filters) - # if no cache, retrieve from Java with filters + # if no cache, retrieve from Backend with filters if filters is not None and not self._cache: - return self._backend('item_elements', ix_type, name, filters, - **self._java_kwargs[ix_type]) + return self._backend('item_elements', ix_type, name, filters) # otherwise, retrieve from Java and keep in python cache - df = self._backend('item_elements', ix_type, name, None, - **self._java_kwargs[ix_type]) + df = self._backend('item_elements', ix_type, name, None) # save if using memcache if self._cache: @@ -991,8 +982,7 @@ def scalar(self, name): ------- {'value': value, 'unit': unit} """ - return self._backend('item_elements', 'par', name, None, - has_value=True) + return self._backend('item_elements', 'par', name, None) def change_scalar(self, name, val, unit, comment=None): """Set the value and unit of a scalar. From e7703a128658ef9e30d950b40798fee245316d10 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 18:21:30 +0100 Subject: [PATCH 61/71] Streamline Scenario.add_par to use only a single backend call --- ixmp/core.py | 164 ++++++++++++++++++++++++++++---------------------- ixmp/utils.py | 2 - 2 files changed, 93 insertions(+), 73 deletions(-) diff --git a/ixmp/core.py b/ixmp/core.py index f5681a41a..963c8d525 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -1,4 +1,4 @@ -# coding=utf-8 +from functools import partial from itertools import repeat, zip_longest import logging from warnings import warn @@ -654,8 +654,8 @@ def _keys(self, name, key_or_keys): return as_str_list(key_or_keys) elif isinstance(key_or_keys, (pd.DataFrame, dict)): if isinstance(key_or_keys, dict): - key_or_keys = pd.DataFrame.from_dict( - key_or_keys, orient='columns', dtype=None) + key_or_keys = pd.DataFrame.from_dict(key_or_keys, + orient='columns') idx_names = self.idx_names(name) return [as_str_list(row, idx_names) for _, row in key_or_keys.iterrows()] @@ -872,87 +872,109 @@ def par(self, name, filters=None, **kwargs): """ return self._element('par', name, filters, **kwargs) - def add_par(self, name, key, val=None, unit=None, comment=None): + def add_par(self, name, key_or_data=None, value=None, unit=None, + comment=None, key=None, val=None): """Set the values of a parameter. Parameters ---------- name : str Name of the parameter. - key : str, list/range of strings/values, dictionary, dataframe - element(s) to be added - val : values, list/range of values, optional - element values (only used if 'key' is a string or list/range) - unit : str, list/range of strings, optional - element units (only used if 'key' is a string or list/range) - comment : str or list/range of strings, optional - comment (optional, only used if 'key' is a string or list/range) + key_or_data : str or iterable of str or dict or pandas.DataFrame. + Element(s) to be added. + value : numeric or iterable of numeric, optional + Values. + unit : str or iterable of str, optional + Unit symbols. + comment : str or iterable of str, optional + Comment(s) for the added values. """ - self.clear_cache(name=name, ix_type='par') - - if isinstance(key, range): - key = list(key) - - elements = [] + # Number of dimensions in the index of *name* + idx_names = self.idx_names(name) + N_dim = len(idx_names) + + if key: + warn("Scenario.add_par(key=...) deprecated and will be removed in " + "ixmp 2.0; use key_or_data", DeprecationWarning) + key_or_data = key + if val: + warn("Scenario.add_par(val=...) deprecated and will be removed in " + "ixmp 2.0; use value", DeprecationWarning) + value = val + + # Convert valid forms of arguments to pd.DataFrame + if isinstance(key_or_data, dict): + # dict containing data + data = pd.DataFrame.from_dict(key_or_data, orient='columns') + elif isinstance(key_or_data, pd.DataFrame): + data = key_or_data + if 'value' in data.columns and value is not None: + raise ValueError('both key_or_data.value and value supplied') + else: + # One or more keys; convert to a list of strings + keys = self._keys(name, key_or_data) + + # Check the type of value + if isinstance(value, (float, int)): + # Single value + values = [float(value)] + + if N_dim > 1 and len(keys) == N_dim: + # Ambiguous case: ._key() above returns ['dim_0', 'dim_1'], + # when we really want [['dim_0', 'dim_1']] + keys = [keys] + else: + # Multiple values + values = value + + data = pd.DataFrame(zip_longest(keys, values), + columns=['key', 'value']) + if data.isna().any(axis=None): + raise ValueError('Length mismatch between keys and values') + + # Column types + types = { + 'key': str if N_dim == 1 else object, + 'value': float, + 'unit': str, + 'comment': str, + } - if isinstance(key, pd.DataFrame) and "key" in list(key): - if "comment" in list(key): - for i in key.index: - elements.append((str(key['key'][i]), - float(key['value'][i]), - str(key['unit'][i]), - str(key['comment'][i]))) + # Further handle each column + if 'key' not in data.columns: + # Form the 'key' column from other columns + if N_dim > 1: + data['key'] = data.apply(partial(as_str_list, + idx_names=idx_names), + axis=1) else: - for i in key.index: - elements.append((str(key['key'][i]), - float(key['value'][i]), - str(key['unit'][i]))) - - elif isinstance(key, pd.DataFrame) or isinstance(key, dict): - if isinstance(key, dict): - key = pd.DataFrame.from_dict(key, orient='columns', dtype=None) - idx_names = self.idx_names(name) - if "comment" in list(key): - for i in key.index: - elements.append((as_str_list(key.loc[i], idx_names), - float(key['value'][i]), - str(key['unit'][i]), - str(key['comment'][i]))) + data['key'] = data[idx_names[0]] + + if 'unit' not in data.columns: + # Broadcast single unit across all values. pandas raises ValueError + # if *unit* is iterable but the wrong length + data['unit'] = unit or '???' + + if 'comment' not in data.columns: + if comment: + # Broadcast single comment across all values. pandas raises + # ValueError if *comment* is iterable but the wrong length + data['comment'] = comment else: - for i in key.index: - elements.append((as_str_list(key.loc[i], idx_names), - float(key['value'][i]), - str(key['unit'][i]), - None)) - elif isinstance(key, list) and isinstance(key[0], list): - # FIXME filling with non-SI units '???' requires special handling - # later by ixmp.reporting - unit = unit or ["???"] * len(key) - for i in range(len(key)): - if comment and i < len(comment): - elements.append((as_str_list(key[i]), float(val[i]), - str(unit[i]), str(comment[i]))) - else: - elements.append((as_str_list(key[i]), float(val[i]), - str(unit[i]), None)) - elif isinstance(key, list) and isinstance(val, list): - # FIXME filling with non-SI units '???' requires special handling - # later by ixmp.reporting - unit = unit or ["???"] * len(key) - for i in range(len(key)): - if comment and i < len(comment): - elements.append((str(key[i]), float(val[i]), - str(unit[i]), str(comment[i]))) - else: - elements.append((str(key[i]), float(val[i]), - str(unit[i]), None)) - elif isinstance(key, list) and not isinstance(val, list): - elements.append((as_str_list(key), float(val), unit, comment)) - else: - elements.append((str(key), float(val), unit, comment)) + # Store a 'None' comment + data['comment'] = None + types.pop('comment') + # Convert types, generate tuples + elements = ((e.key, e.value, e.unit, e.comment) + for e in data.astype(types).itertuples()) + + # Store self._backend('add_par_values', name, elements) + # Clear cache + self.clear_cache(name=name, ix_type='par') + def init_scalar(self, name, val, unit, comment=None): """Initialize a new scalar. diff --git a/ixmp/utils.py b/ixmp/utils.py index bbaf4b402..dc2c2c32b 100644 --- a/ixmp/utils.py +++ b/ixmp/utils.py @@ -1,7 +1,5 @@ import collections import logging -import os -from pathlib import Path import pandas as pd import six From 6de5ab90c1a16cba8f0a84d8f45313142ca3b477 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 18:35:37 +0100 Subject: [PATCH 62/71] Re-add range as valid key type for Scenario.add_par --- ixmp/core.py | 5 ++++- tests/test_core.py | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ixmp/core.py b/ixmp/core.py index 963c8d525..047fd6a37 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -880,7 +880,8 @@ def add_par(self, name, key_or_data=None, value=None, unit=None, ---------- name : str Name of the parameter. - key_or_data : str or iterable of str or dict or pandas.DataFrame. + key_or_data : str or iterable of str or range or dict or \ + pandas.DataFrame Element(s) to be added. value : numeric or iterable of numeric, optional Values. @@ -912,6 +913,8 @@ def add_par(self, name, key_or_data=None, value=None, unit=None, raise ValueError('both key_or_data.value and value supplied') else: # One or more keys; convert to a list of strings + if isinstance(key_or_data, range): + key_or_data = list(key_or_data) keys = self._keys(name, key_or_data) # Check the type of value diff --git a/tests/test_core.py b/tests/test_core.py index a7c7cf212..f8f2d7ccb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -76,11 +76,20 @@ def test_has_set(test_mp): assert not scen.has_set('k') -def test_init_par_35(test_mp): +def test_range(test_mp): scen = ixmp.Scenario(test_mp, *can_args, version='new') + scen.init_set('ii') + ii = range(1, 20, 2) + + # range instance is automatically converted to list of str in add_set + scen.add_set('ii', ii) + scen.init_par('new_par', idx_sets='ii') + # range instance is a valid key argument to add_par + scen.add_par('new_par', ii, [1.2] * len(ii)) + def test_get_scalar(test_mp): scen = ixmp.Scenario(test_mp, *can_args) From 65468cd092b806b4bb555e41b2f524a1b2b61f1f Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 19:06:44 +0100 Subject: [PATCH 63/71] Document more Backend methods --- ixmp/backend/base.py | 95 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 4b6168a68..ad27bd2d4 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -636,16 +636,22 @@ def s_item_elements(self, s, type, name, filters=None): def s_add_set_elements(self, s, name, elements): """Add elements to set *name* in Scenario *s*. - .. todo:: Use a table for *elements*. - Parameters ---------- - elements : iterable of 2-tuples - The tuple members are, respectively: + name : str + Name of an existing *set*. + elements : iterable of 2-tuple + The members of each tuple are: - 1. Key: str or list of str. The number and order of key dimensions - must match the index of *name*, if any. - 2. Comment: str or None. An optional description of the key. + ======= ================== === + ID Type Description + ======= ================== === + key str or list of str New set elements + comment str or None Description of the key. + ======= ================== === + + If *name* is indexed by other set(s), then the number of elements + of each *key*, and their contents, must match the index set(s). Raises ------ @@ -654,24 +660,34 @@ def s_add_set_elements(self, s, name, elements): values not in the index set(s). Exception If the Backend encounters any error adding the key. + + See also + -------- + s_init_item + s_item_delete_elements """ @abstractmethod def s_add_par_values(self, s, name, elements): """Add values to parameter *name* in Scenario *s*. - .. todo:: Use a table for *elements*. Rename to s_set_data_par. - Parameters ---------- - elements : iterable of 4-tuples - The tuple members are, respectively: + name : name of + elements : iterable of 4-tuple + The members of each tuple are: - 1. Key: str or list of str or (for a scalar, or 0-dimensional - parameter) None. - 2. Value: float. - 3. Unit: str or None. - 4. Comment: str or None. + ======= ========================== === + ID Type Description + ======= ========================== === + key str or list of str or None Indices for the value. + value float Value + unit str or None Unit symbol + comment str or None Description of the change + ======= ========================== === + + If *name* is indexed by other set(s), then the number of elements + of each *key*, and their contents, must match the index set(s). Raises ------ @@ -680,13 +696,33 @@ def s_add_par_values(self, s, name, elements): index set(s). Exception If the Backend encounters any error adding the parameter values. + + See also + -------- + s_init_item + s_item_delete_elements """ @abstractmethod - def s_item_delete_elements(self, s, type, name, key): + def s_item_delete_elements(self, s, type, name, keys): """Remove elements of item *name*. - .. todo:: Document. + Parameters + ---------- + type : 'par' or 'set' + name : str + keys : iterable of iterable of str + If *name* is indexed by other set(s), then the number of elements + of each key in *keys*, and their contents, must match the index + set(s). + If *name* is a basic set, then each key must be a list containing a + single str, which must exist in the set. + + See also + -------- + s_init_item + s_add_par_values + s_add_set_elements """ @abstractmethod @@ -764,12 +800,31 @@ def ms_cat_get_elements(self, ms, name, cat): def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): """Add elements to category mapping. - .. todo:: Document. + Parameters + ---------- + name : str + Name of the category mapping set. + cat : str + Name of the category within *name*. + keys : iterable of str or list of str + Keys to add to *cat*. + is_unique : bool + If :obj:`True`: + + - *keys* **must** contain only one key. + - The Backend **must** remove any existing member of *cat*, so that + it has only one element. + + Returns + ------- + None """ @abstractmethod def ms_years_active(self, ms, node, tec, year_vintage): """Return a list of years in which *tec* is active. - .. todo:: Document. + Returns + ------- + list of ? """ From d542adb62cba3fadd1f67202652882c450b18211 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 19:55:19 +0100 Subject: [PATCH 64/71] Remove ms_years_active from Backend --- doc/source/api-backend.rst | 1 - ixmp/backend/base.py | 9 --------- ixmp/backend/jdbc.py | 3 --- 3 files changed, 13 deletions(-) diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index 5fc448758..e4de2a96b 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -143,4 +143,3 @@ Backend API ms_cat_get_elements ms_cat_list ms_cat_set_elements - ms_years_active diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index ad27bd2d4..4c6f45d54 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -819,12 +819,3 @@ def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): ------- None """ - - @abstractmethod - def ms_years_active(self, ms, node, tec, year_vintage): - """Return a list of years in which *tec* is active. - - Returns - ------- - list of ? - """ diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 42597958e..7ec208793 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -512,9 +512,6 @@ def ms_cat_get_elements(self, ms, name, cat): def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): self.jindex[ms].addCatEle(name, cat, to_jlist2(keys), is_unique) - def ms_years_active(self, ms, node, tec, year_vintage): - return list(self.jindex[ms].getTecActYrs(node, tec, year_vintage)) - # Helpers; not part of the Backend interface def s_write_gdx(self, s, path): From 4978a3773c026a904024459d239f62096d8c788d Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 20:24:47 +0100 Subject: [PATCH 65/71] Merge Backend.s_add_set_elements and .add_par_values --- doc/source/api-backend.rst | 5 ++- ixmp/backend/base.py | 69 ++++++++++---------------------------- ixmp/backend/jdbc.py | 46 ++++++++++--------------- ixmp/core.py | 13 +++---- 4 files changed, 45 insertions(+), 88 deletions(-) diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index e4de2a96b..757bb6ef0 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -118,8 +118,6 @@ Backend API .. autosummary:: :nosignatures: - s_add_par_values - s_add_set_elements s_clone s_delete_item s_get @@ -128,7 +126,8 @@ Backend API s_init s_init_item s_item_delete_elements - s_item_elements + s_item_get_elements + s_item_set_elements s_item_index s_list_items s_set_meta diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 4c6f45d54..dc7b34771 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -341,7 +341,7 @@ def ts_get_geo(self, ts): @abstractmethod def ts_set_data(self, ts, region, variable, data, unit, meta): - """Store *data* in *ts*. + """Store *data*. Parameters ---------- @@ -383,7 +383,7 @@ def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): @abstractmethod def ts_delete(self, ts, region, variable, years, unit): - """Remove data values from *ts*. + """Remove data values. Parameters ---------- @@ -403,7 +403,7 @@ def ts_delete(self, ts, region, variable, years, unit): @abstractmethod def ts_delete_geo(self, ts, region, variable, time, years, unit): - """Remove 'geodata' values from *ts*. + """Remove 'geodata' values. Parameters ---------- @@ -507,7 +507,7 @@ def s_init(self, s, scheme, annotation): @abstractmethod def s_get(self, s, version): - """Retrieve the existing Scenario *ts*. + """Retrieve the existing Scenario *s*. The Scenario is identified based on its (:attr:`~.TimeSeries.model`, :attr:`~.TimeSeries.scenario`) and *version*. s_get **must** set @@ -564,7 +564,7 @@ def s_has_solution(self, s): @abstractmethod def s_list_items(self, s, type): - """Return a list of items of *type* in Scenario *s*. + """Return a list of items of *type*. Parameters ---------- @@ -577,7 +577,7 @@ def s_list_items(self, s, type): @abstractmethod def s_init_item(self, s, type, name): - """Initialize an item *name* of *type* in Scenario *s*. + """Initialize an item *name* of *type*. Parameters ---------- @@ -592,7 +592,7 @@ def s_init_item(self, s, type, name): @abstractmethod def s_delete_item(self, s, type, name): - """Remove an item *name* of *type* in Scenario *s*. + """Remove an item *name* of *type*. Parameters ---------- @@ -619,8 +619,8 @@ def s_item_index(self, s, name, sets_or_names): """ @abstractmethod - def s_item_elements(self, s, type, name, filters=None): - """Return elements of item *name* in Scenario *s*. + def s_item_get_elements(self, s, type, name, filters=None): + """Return elements of item *name*. The return type varies according to the *type* and contents: @@ -633,61 +633,29 @@ def s_item_elements(self, s, type, name, filters=None): """ @abstractmethod - def s_add_set_elements(self, s, name, elements): - """Add elements to set *name* in Scenario *s*. + def s_item_set_elements(self, s, type, name, elements): + """Add keys or values to item *name*. Parameters ---------- + type : 'par' or 'set' name : str - Name of an existing *set*. - elements : iterable of 2-tuple - The members of each tuple are: - - ======= ================== === - ID Type Description - ======= ================== === - key str or list of str New set elements - comment str or None Description of the key. - ======= ================== === - - If *name* is indexed by other set(s), then the number of elements - of each *key*, and their contents, must match the index set(s). - - Raises - ------ - ValueError - If *elements* contain invalid values, e.g. for an indexed set, - values not in the index set(s). - Exception - If the Backend encounters any error adding the key. - - See also - -------- - s_init_item - s_item_delete_elements - """ - - @abstractmethod - def s_add_par_values(self, s, name, elements): - """Add values to parameter *name* in Scenario *s*. - - Parameters - ---------- - name : name of + Name of the items. elements : iterable of 4-tuple The members of each tuple are: ======= ========================== === ID Type Description ======= ========================== === - key str or list of str or None Indices for the value. - value float Value + key str or list of str or None Set elements or value indices + value float or None Parameter value unit str or None Unit symbol comment str or None Description of the change ======= ========================== === If *name* is indexed by other set(s), then the number of elements of each *key*, and their contents, must match the index set(s). + When *type* is 'set', *value* and *unit* **must** be :obj:`None`. Raises ------ @@ -695,7 +663,7 @@ def s_add_par_values(self, s, name, elements): If *elements* contain invalid values, e.g. key values not in the index set(s). Exception - If the Backend encounters any error adding the parameter values. + If the Backend encounters any error adding the elements. See also -------- @@ -721,8 +689,7 @@ def s_item_delete_elements(self, s, type, name, keys): See also -------- s_init_item - s_add_par_values - s_add_set_elements + s_item_set_elements """ @abstractmethod diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 7ec208793..9f0fddd7c 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -377,7 +377,7 @@ def s_item_index(self, s, name, sets_or_names): jitem = self._get_item(s, 'item', name, load=False) return list(getattr(jitem, f'getIdx{sets_or_names.title()}')()) - def s_item_elements(self, s, type, name, filters=None): + def s_item_get_elements(self, s, type, name, filters=None): # Retrieve the item item = self._get_item(s, type, name, load=True) @@ -437,19 +437,25 @@ def s_item_elements(self, s, type, name, filters=None): return data - def s_add_set_elements(self, s, name, elements): - # Retrieve the Java Set and its number of dimensions - jSet = self._get_item(s, 'set', name) - dim = jSet.getDim() + def s_item_set_elements(self, s, type, name, elements): + jobj = self._get_item(s, type, name) try: - for e, comment in elements: - if dim: - # Convert e to a JLinkedList - e = to_jlist2(e) - - # Call with 1 or 2 args - jSet.addElement(e, comment) if comment else jSet.addElement(e) + for key, value, unit, comment in elements: + # Prepare arguments + args = [to_jlist2(key)] if key else [] + if type == 'par': + args.extend([java.Double(value), unit]) + if comment: + args.append(comment) + + # Activates one of 5 signatures for addElement: + # - set: (key) + # - set: (key, comment) + # - par: (key, value, unit, comment) + # - par: (key, value, unit) + # - par: (value, unit, comment) + jobj.addElement(*args) except java.IxException as e: msg = e.message() if 'does not have an element' in msg: @@ -458,22 +464,6 @@ def s_add_set_elements(self, s, name, elements): else: raise RuntimeError('Unhandled Java exception') from e - def s_add_par_values(self, s, name, elements): - jPar = self._get_item(s, 'par', name) - - for key, value, unit, comment in elements: - args = [java.Double(value), unit] - if key: - args.insert(0, to_jlist2(key)) - if comment: - args.append(comment) - - # Activates one of 3 signatures for addElement: - # - (key, value, unit, comment) - # - (key, value, unit) - # - (value, unit, comment) - jPar.addElement(*args) - def s_item_delete_elements(self, s, type, name, keys): jitem = self._get_item(s, type, name, load=False) for key in keys: diff --git a/ixmp/core.py b/ixmp/core.py index 047fd6a37..381910c85 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -618,10 +618,10 @@ def _element(self, ix_type, name, filters=None, cache=None): # if no cache, retrieve from Backend with filters if filters is not None and not self._cache: - return self._backend('item_elements', ix_type, name, filters) + return self._backend('item_get_elements', ix_type, name, filters) # otherwise, retrieve from Java and keep in python cache - df = self._backend('item_elements', ix_type, name, None) + df = self._backend('item_get_elements', ix_type, name, None) # save if using memcache if self._cache: @@ -817,7 +817,8 @@ def add_set(self, name, key, comment=None): f'{len(idx_names)}-D set {name}{idx_names!r}') # Send to backend - self._backend('add_set_elements', name, to_add) + elements = ((kc[0], None, None, kc[1]) for kc in to_add) + self._backend('item_set_elements', 'set', name, elements) def remove_set(self, name, key=None): """delete a set from the scenario @@ -973,7 +974,7 @@ def add_par(self, name, key_or_data=None, value=None, unit=None, for e in data.astype(types).itertuples()) # Store - self._backend('add_par_values', name, elements) + self._backend('item_set_elements', 'par', name, elements) # Clear cache self.clear_cache(name=name, ix_type='par') @@ -1007,7 +1008,7 @@ def scalar(self, name): ------- {'value': value, 'unit': unit} """ - return self._backend('item_elements', 'par', name, None) + return self._backend('item_get_elements', 'par', name, None) def change_scalar(self, name, val, unit, comment=None): """Set the value and unit of a scalar. @@ -1024,7 +1025,7 @@ def change_scalar(self, name, val, unit, comment=None): Description of the change. """ self.clear_cache(name=name, ix_type='par') - self._backend('add_par_values', name, + self._backend('item_set_elements', 'par', name, [(None, float(val), unit, comment)]) def remove_par(self, name, key=None): From 797f3c130a42e35ebbf3174ec4d33d379ad9b18e Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Sun, 27 Oct 2019 20:51:09 +0100 Subject: [PATCH 66/71] Streamline and document Backend.s_item_get_elements --- ixmp/backend/base.py | 29 +++++++++++++---- ixmp/backend/jdbc.py | 77 +++++++++++++++++++------------------------- 2 files changed, 56 insertions(+), 50 deletions(-) diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index dc7b34771..1f802f02e 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -622,14 +622,29 @@ def s_item_index(self, s, name, sets_or_names): def s_item_get_elements(self, s, type, name, filters=None): """Return elements of item *name*. - The return type varies according to the *type* and contents: - - - Scalars vs. parameters. - - Lists, e.g. set elements. - - Mapping sets. - - Multi-dimensional parameters, equations, or variables. + Parameters + ---------- + type : 'equ' or 'par' or 'set' or 'var' + name : str + Name of the item. + filters : dict (str -> list of str), optional + If provided, a mapping from dimension names to allowed values + along that dimension. - .. todo:: Exactly specify the return types. + Returns + ------- + pandas.Series + When *type* is 'set' and *name* an index set (not indexed by other + sets). + dict + When *type* is 'equ', 'par', or 'set' and *name* is scalar (zero- + dimensional). The value has the keys 'value' and 'unit' (for 'par') + or 'lvl' and 'mrg' (for 'equ' or 'var'). + pandas.DataFrame + For mapping sets, or all 1+-dimensional values. The dataframe has + one column per index name with dimension values; plus the columns + 'value' and 'unit' (for 'par') or 'lvl' and 'mrg' (for 'equ' or + 'var'). """ @abstractmethod diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 9f0fddd7c..153ff2ed5 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -381,61 +381,52 @@ def s_item_get_elements(self, s, type, name, filters=None): # Retrieve the item item = self._get_item(s, type, name, load=True) - # get list of elements, with filter HashMap if provided + # Get list of elements, using filters if provided if filters is not None: jFilter = java.HashMap() - for idx_name, values in filters.items(): - jFilter.put(idx_name, to_jlist(values)) + for dim, values in filters.items(): + jFilter.put(dim, to_jlist(values)) jList = item.getElements(jFilter) else: jList = item.getElements() - # return a dataframe if this is a mapping or multi-dimensional - # parameter - dim = item.getDim() - if dim > 0: - idx_names = np.array(item.getIdxNames().toArray()[:]) - idx_sets = np.array(item.getIdxSets().toArray()[:]) + if item.getDim() > 0: + # Mapping set or multi-dimensional equation, parameter, or variable + idx_names = list(item.getIdxNames()) + idx_sets = list(item.getIdxSets()) data = {} - for d in range(dim): - ary = np.array(item.getCol(d, jList)[:]) - if idx_sets[d] == "year": - # numpy tricks to avoid extra copy - # _ary = ary.view('int') - # _ary[:] = ary - ary = ary.astype('int') - data[idx_names[d]] = ary + types = {} - if type == 'par': - data['value'] = np.array(item.getValues(jList)[:]) - data['unit'] = np.array(item.getUnits(jList)[:]) - - if type in ('equ', 'var'): - data['lvl'] = np.array(item.getLevels(jList)[:]) - data['mrg'] = np.array(item.getMarginals(jList)[:]) - - df = pd.DataFrame.from_dict(data, orient='columns', dtype=None) - return df - - else: - # for index sets - if type == 'set': - return pd.Series(item.getCol(0, jList)[:]) - - data = {} + # Retrieve index columns + for d, (d_name, d_set) in enumerate(zip(idx_names, idx_sets)): + data[d_name] = item.getCol(d, jList) + if d_set == 'year': + # Record column for later type conversion + types[d_name] = int - # for parameters as scalars + # Retrieve value columns if type == 'par': - data['value'] = item.getScalarValue().floatValue() - data['unit'] = str(item.getScalarUnit()) + data['value'] = item.getValues(jList) + data['unit'] = item.getUnits(jList) - # for variables as scalars - elif type in ('equ', 'var'): - data['lvl'] = item.getScalarLevel().floatValue() - data['mrg'] = item.getScalarMarginal().floatValue() - - return data + if type in ('equ', 'var'): + data['lvl'] = item.getLevels(jList) + data['mrg'] = item.getMarginals(jList) + + return pd.DataFrame.from_dict(data, orient='columns') \ + .astype(types) + elif type == 'set': + # Index sets + return pd.Series(item.getCol(0, jList)) + elif type == 'par': + # Scalar parameters + return dict(value=item.getScalarValue().floatValue(), + unit=str(item.getScalarUnit())) + elif type in ('equ', 'var'): + # Scalar equations and variables + return dict(lvl=item.getScalarLevel().floatValue(), + mrg=item.getScalarMarginal().floatValue()) def s_item_set_elements(self, s, type, name, elements): jobj = self._get_item(s, type, name) From 05629cab0d7cfea224614445b5f6d02f583e0998 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 31 Oct 2019 11:27:10 +0100 Subject: [PATCH 67/71] Remove 'Java' section in API docs --- doc/source/api.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/doc/source/api.rst b/doc/source/api.rst index e3c000184..c7aa0f443 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -113,10 +113,3 @@ a GDX-format file created by *ixmp*, and write its output data to a GDX file specified by *ixmp*. *ixmp* then automatically retrieves the model solution and other information from the output file, updating the :class:`ixmp.Scenario` and database. - - -Java ----- - -The `ixmp` is powered by a Java interface to connect a database instance with -the scientific programming interfaces. From d73f8b17484d03fa137429d84d2e8ee353c76a44 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 31 Oct 2019 11:27:26 +0100 Subject: [PATCH 68/71] Correct R description in API docs --- doc/source/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/source/api.rst b/doc/source/api.rst index c7aa0f443..cde3e242d 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -30,6 +30,7 @@ An R interface to the `ixmp` is provided by the ``rixmp`` package. # Load the rixmp package library(rixmp) + ixmp <- import('ixmp') # An 'ixmp' object is added to the global namespace. # It can be used in the same way as the Python ixmp package. From 8225302e8425749791c441b2bc50bceea5b91484 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 31 Oct 2019 11:43:31 +0100 Subject: [PATCH 69/71] Move GAMS adapter docs --- doc/source/api.rst | 36 ------------------------------------ doc/source/tutorials.rst | 35 +++++++++++++++++++++++++++++++++++ tutorial/README.rst | 5 +---- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/doc/source/api.rst b/doc/source/api.rst index cde3e242d..5208d50b0 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -78,39 +78,3 @@ With ``rixmp`` the user can load entire sets of strings or dataframes, which req scen$init_par("a", c("i")) a.df = data.frame( i = i.set, value = c(350 , 600) , unit = 'cases') scen$add_par("a", adapt_to_ret(a.df)) - - -.. _gams-api: - -GAMS ----- - -The *ixmp* :doc:`tutorials ` use a common example problem from -Dantzig :cite:`dantzig-1963`, implemented in GAMS and available -`from the GAMS website `_. -The file ``tutorial/transport/transport_ixmp.gms`` illustrates how such an -existing GAMS model can be simply adapted to work with the |ixmp|. - -The steps are: - -1. The GAMS definitions of sets ``i`` and ``j``, and parameters ``a``, ``b``, - ``d``, and ``f``, are modified to **remove explicit values**. -2. The following lines are added **before** the model definition and solution:: - - $if not set in $setglobal in 'ix_transport_data.gdx' - $if not set out $setglobal out 'ix_transport_results.gdx' - - $gdxin '%in%' - $load i, j, a, b, d, f - $gdxin - -3. The following line is added **after** the model's ``solve ...;`` statement:: - - execute_unload '%out%'; - -*ixmp* uses GAMS command-line options to pass the values of the compile-time -variables ``in`` and ``out``. This causes the model to read its input data from -a GDX-format file created by *ixmp*, and write its output data to a GDX file -specified by *ixmp*. *ixmp* then automatically retrieves the model solution and -other information from the output file, updating the :class:`ixmp.Scenario` and -database. diff --git a/doc/source/tutorials.rst b/doc/source/tutorials.rst index c3db975de..d187fb0c3 100644 --- a/doc/source/tutorials.rst +++ b/doc/source/tutorials.rst @@ -1 +1,36 @@ .. include:: ../../tutorial/README.rst + +Adapting GAMS models for :mod:`ixmp` +------------------------------------ + +The common example optimization from Dantzig :cite:`dantzig-1963`, is available in a GAMS implementation +`from the GAMS website `_. +The file ``tutorial/transport/transport_ixmp.gms`` illustrates how an +existing GAMS model can be adapted to work with the |ixmp|. +The same, simple procedure can be applied to any GAMS code. + +The steps are: + +1. Modify the definitions of GAMS sets (``i`` and ``j``) and parameters (``a``, ``b``, ``d``, and ``f``) to **remove explicit values**. +2. Add lines to **read the model input data passed by ixmp**. + + The following lines are added *before* the code that defines and solves the model:: + + * These two lines let the model code be run outside of ixmp, if needed + $if not set in $setglobal in 'ix_transport_data.gdx' + $if not set out $setglobal out 'ix_transport_results.gdx' + + $gdxin '%in%' + $load i, j, a, b, d, f + $gdxin + + +3. Add a line to **write the model output data**. + + The following line is added *after* the model's ``solve ...;`` statement:: + + execute_unload '%out%'; + +*ixmp*'s :class:`~.GAMSModel` class uses command-line options to pass the values of the variables ``in`` and ``out``. +This causes the model to read its input data from a GDX-format file created by *ixmp*, and write its output data to a GDX file specified by *ixmp*. +*ixmp* then automatically retrieves the model solution and other information from the output file, updating the :class:`~.Scenario` and storage :class:`~.Backend`. diff --git a/tutorial/README.rst b/tutorial/README.rst index dc48e1bf9..31e417757 100644 --- a/tutorial/README.rst +++ b/tutorial/README.rst @@ -28,7 +28,4 @@ The tutorials are provided as Jupyter notebooks for both Python and R, and are i in `Python `__, or in `R `__. - This tutorial creates an alternate or ‘counterfactual’ scenario of the transport problem; solve it; and compare the results to the original or reference scenario. - - -See :ref:`the GAMS API description ` for an explanation of how the standard GAMS transport problem code is easily adapted for use with *ixmp*. + This tutorial creates an alternate or ‘counterfactual’ scenario of the transport problem; solves it; and compares the results to the original or reference scenario. From 242178898c3bb121941b86623c28576e603fbc75 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 31 Oct 2019 13:41:44 +0100 Subject: [PATCH 70/71] Remove prefixes on backend names; streamline import hierarchy --- doc/source/api-backend.rst | 76 +++++++++++++++--------------- ixmp/__init__.py | 21 +++++++-- ixmp/backend/__init__.py | 20 +++++--- ixmp/backend/base.py | 96 +++++++++++++++----------------------- ixmp/backend/jdbc.py | 75 ++++++++++++++--------------- ixmp/core.py | 14 ++---- ixmp/model/__init__.py | 8 +--- ixmp/model/base.py | 2 +- 8 files changed, 151 insertions(+), 161 deletions(-) diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index 757bb6ef0..84e3a40f9 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -18,7 +18,7 @@ Provided backends .. currentmodule:: ixmp.backend.jdbc .. autoclass:: ixmp.backend.jdbc.JDBCBackend - :members: s_write_gdx, s_read_gdx + :members: read_gdx, write_gdx JDBCBackend supports: @@ -35,8 +35,8 @@ Provided backends .. autosummary:: :nosignatures: - s_write_gdx - s_read_gdx + read_gdx + write_gdx .. automethod:: ixmp.backend.jdbc.start_jvm @@ -51,9 +51,9 @@ Backend API - :class:`Platform ` code is not affected by where and how data is stored; it merely handles user arguments and then makes, usually, a single :class:`Backend` call. - :class:`Backend` code does not need to perform argument checking; merely store and retrieve data reliably. -.. currentmodule:: ixmp.backend.base +.. autodata:: ixmp.backend.FIELDS -.. autodata:: ixmp.backend.base.FIELDS +.. currentmodule:: ixmp.backend.base .. autoclass:: ixmp.backend.base.Backend :members: @@ -94,22 +94,22 @@ Backend API .. autosummary:: :nosignatures: - ts_check_out - ts_commit - ts_delete - ts_delete_geo - ts_discard_changes - ts_get - ts_get_data - ts_get_geo - ts_init - ts_is_default - ts_last_update - ts_preload - ts_run_id - ts_set_data - ts_set_as_default - ts_set_geo + check_out + commit + delete + delete_geo + discard_changes + get_ts + get_data + get_geo + init_ts + is_default + last_update + preload + run_id + set_data + set_as_default + set_geo Methods related to :class:`ixmp.Scenario`: @@ -118,27 +118,29 @@ Backend API .. autosummary:: :nosignatures: - s_clone - s_delete_item - s_get - s_get_meta - s_has_solution - s_init - s_init_item - s_item_delete_elements - s_item_get_elements - s_item_set_elements - s_item_index - s_list_items - s_set_meta + clone + delete_item + get_s + get_meta + has_solution + init_s + init_item + item_delete_elements + item_get_elements + item_set_elements + item_index + list_items + set_meta Methods related to :class:`message_ix.Scenario`: - Each method has an argument `ms`, a reference to the Scenario object being manipulated. + .. warning:: These methods may be moved to ixmp in a future release. + .. autosummary:: :nosignatures: - ms_cat_get_elements - ms_cat_list - ms_cat_set_elements + cat_get_elements + cat_list + cat_set_elements diff --git a/ixmp/__init__.py b/ixmp/__init__.py index c8cf2d339..1aa7c62fd 100644 --- a/ixmp/__init__.py +++ b/ixmp/__init__.py @@ -1,11 +1,24 @@ from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions - -from ixmp.core import ( # noqa: E402,F401 +from ixmp.core import ( # noqa: F401 IAMC_IDX, Platform, TimeSeries, Scenario, ) +from .backend import BACKENDS +from .backend.jdbc import JDBCBackend +from .model import MODELS +from .model.gams import GAMSModel from ixmp.reporting import Reporter # noqa: F401 + +__version__ = get_versions()['version'] +del get_versions + +# Register Backends provided by ixmp +BACKENDS['jdbc'] = JDBCBackend + +# Register Models provided by ixmp +MODELS.update({ + 'default': GAMSModel, + 'gams': GAMSModel, +}) diff --git a/ixmp/backend/__init__.py b/ixmp/backend/__init__.py index 00e065b5f..bf363d02b 100644 --- a/ixmp/backend/__init__.py +++ b/ixmp/backend/__init__.py @@ -1,8 +1,16 @@ -from .base import FIELDS # noqa: F401 -from .jdbc import JDBCBackend +#: Lists of field names for tuples returned by Backend API methods. +FIELDS = { + 'get_nodes': ('region', 'mapped_to', 'parent', 'hierarchy'), + 'get_scenarios': ('model', 'scenario', 'scheme', 'is_default', + 'is_locked', 'cre_user', 'cre_date', 'upd_user', + 'upd_date', 'lock_user', 'lock_date', 'annotation', + 'version'), + 'ts_get': ('region', 'variable', 'unit', 'year', 'value'), + 'ts_get_geo': ('region', 'variable', 'time', 'year', 'value', 'unit', + 'meta'), +} -#: Mapping from names to available backends -BACKENDS = { - 'jdbc': JDBCBackend, -} +#: Mapping from names to available backends. To register additional backends, +#: add elements to this variable. +BACKENDS = {} diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 1f802f02e..3bde2defe 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -1,18 +1,6 @@ -from functools import lru_cache from abc import ABC, abstractmethod - -#: Lists of field names for tuples returned by Backend API methods. -FIELDS = { - 'get_nodes': ('region', 'mapped_to', 'parent', 'hierarchy'), - 'get_scenarios': ('model', 'scenario', 'scheme', 'is_default', - 'is_locked', 'cre_user', 'cre_date', 'upd_user', - 'upd_date', 'lock_user', 'lock_date', 'annotation', - 'version'), - 'ts_get': ('region', 'variable', 'unit', 'year', 'value'), - 'ts_get_geo': ('region', 'variable', 'time', 'year', 'value', 'unit', - 'meta'), -} +from ixmp.core import TimeSeries, Scenario class Backend(ABC): @@ -26,24 +14,13 @@ def __init__(self): # pragma: no cover """OPTIONAL: Initialize the backend.""" pass - @classmethod - @lru_cache() # Don't recompute - def __method(backend_cls, cls, name): - for c in cls.__mro__[:-1]: - try: - return getattr(backend_cls, f'{c._backend_prefix}_{name}') - except AttributeError: - pass - raise AttributeError(f"backend method '{{prefix}}_{name}'") - def __call__(self, obj, method, *args, **kwargs): """Call the backend method *method* for *obj*. The class attribute obj._backend_prefix is used to determine a prefix for the method name, e.g. 'ts_{method}'. """ - method = self.__method(obj.__class__, method) - return method(self, obj, *args, **kwargs) + return getattr(self, method)(obj, *args, **kwargs) def close_db(self): # pragma: no cover """OPTIONAL: Close database connection(s). @@ -216,7 +193,7 @@ def set_unit(self, name, comment): # Methods for ixmp.TimeSeries @abstractmethod - def ts_init(self, ts, annotation=None): + def init_ts(self, ts: TimeSeries, annotation=None): """Initialize the TimeSeries *ts*. ts_init **may** modify the :attr:`~TimeSeries.version` attribute of @@ -234,7 +211,7 @@ def ts_init(self, ts, annotation=None): """ @abstractmethod - def ts_get(self, ts, version): + def get_ts(self, ts: TimeSeries, version): """Retrieve the existing TimeSeries *ts*. The TimeSeries is identified based on its (:attr:`~.TimeSeries.model`, @@ -256,7 +233,7 @@ def ts_get(self, ts, version): """ @abstractmethod - def ts_check_out(self, ts, timeseries_only): + def check_out(self, ts: TimeSeries, timeseries_only): """Check out *ts* for modification. Parameters @@ -270,7 +247,7 @@ def ts_check_out(self, ts, timeseries_only): """ @abstractmethod - def ts_commit(self, ts, comment): + def commit(self, ts: TimeSeries, comment): """Commit changes to *ts*. ts_init **may** modify the :attr:`~.TimeSeries.version` attribute of @@ -287,7 +264,7 @@ def ts_commit(self, ts, comment): """ @abstractmethod - def ts_get_data(self, ts, region, variable, unit, year): + def get_data(self, ts: TimeSeries, region, variable, unit, year): """Retrieve time-series data. Parameters @@ -318,7 +295,7 @@ def ts_get_data(self, ts, region, variable, unit, year): """ @abstractmethod - def ts_get_geo(self, ts): + def get_geo(self, ts: TimeSeries): """Retrieve time-series 'geodata'. Yields @@ -340,7 +317,7 @@ def ts_get_geo(self, ts): """ @abstractmethod - def ts_set_data(self, ts, region, variable, data, unit, meta): + def set_data(self, ts: TimeSeries, region, variable, data, unit, meta): """Store *data*. Parameters @@ -360,7 +337,8 @@ def ts_set_data(self, ts, region, variable, data, unit, meta): """ @abstractmethod - def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): + def set_geo(self, ts: TimeSeries, region, variable, time, year, value, + unit, meta): """Store time-series 'geodata'. Parameters @@ -382,7 +360,7 @@ def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): """ @abstractmethod - def ts_delete(self, ts, region, variable, years, unit): + def delete(self, ts: TimeSeries, region, variable, years, unit): """Remove data values. Parameters @@ -402,7 +380,7 @@ def ts_delete(self, ts, region, variable, years, unit): """ @abstractmethod - def ts_delete_geo(self, ts, region, variable, time, years, unit): + def delete_geo(self, ts: TimeSeries, region, variable, time, years, unit): """Remove 'geodata' values. Parameters @@ -424,7 +402,7 @@ def ts_delete_geo(self, ts, region, variable, time, years, unit): """ @abstractmethod - def ts_discard_changes(self, ts): + def discard_changes(self, ts: TimeSeries): """Discard changes to *ts* since the last :meth:`ts_check_out`. Returns @@ -433,7 +411,7 @@ def ts_discard_changes(self, ts): """ @abstractmethod - def ts_set_as_default(self, ts): + def set_as_default(self, ts: TimeSeries): """Set the current :attr:`.TimeSeries.version` as the default. Returns @@ -448,7 +426,7 @@ def ts_set_as_default(self, ts): """ @abstractmethod - def ts_is_default(self, ts): + def is_default(self, ts: TimeSeries): """Return :obj:`True` if *ts* is the default version. Returns @@ -463,7 +441,7 @@ def ts_is_default(self, ts): """ @abstractmethod - def ts_last_update(self, ts): + def last_update(self, ts: TimeSeries): """Return the date of the last modification of the *ts*. Returns @@ -472,7 +450,7 @@ def ts_last_update(self, ts): """ @abstractmethod - def ts_run_id(self, ts): + def run_id(self, ts: TimeSeries): """Return the run ID for the *ts*. Returns @@ -480,14 +458,14 @@ def ts_run_id(self, ts): int """ - def ts_preload(self, ts): # pragma: no cover + def preload(self, ts: TimeSeries): # pragma: no cover """OPTIONAL: Load *ts* data into memory.""" pass # Methods for ixmp.Scenario @abstractmethod - def s_init(self, s, scheme, annotation): + def init_s(self, s: Scenario, scheme, annotation): """Initialize the Scenario *s*. s_init **may** modify the :attr:`~.TimeSeries.version` attribute of @@ -506,7 +484,7 @@ def s_init(self, s, scheme, annotation): """ @abstractmethod - def s_get(self, s, version): + def get_s(self, s: Scenario, version): """Retrieve the existing Scenario *s*. The Scenario is identified based on its (:attr:`~.TimeSeries.model`, @@ -529,8 +507,8 @@ def s_get(self, s, version): """ @abstractmethod - def s_clone(self, s, platform_dest, model, scenario, annotation, - keep_solution, first_model_year=None): + def clone(self, s: Scenario, platform_dest, model, scenario, annotation, + keep_solution, first_model_year=None): """Clone *s*. Parameters @@ -556,14 +534,14 @@ def s_clone(self, s, platform_dest, model, scenario, annotation, """ @abstractmethod - def s_has_solution(self, s): + def has_solution(self, s: Scenario): """Return `True` if Scenario *s* has been solved. If :obj:`True`, model solution data is available from the Backend. """ @abstractmethod - def s_list_items(self, s, type): + def list_items(self, s: Scenario, type): """Return a list of items of *type*. Parameters @@ -576,7 +554,7 @@ def s_list_items(self, s, type): """ @abstractmethod - def s_init_item(self, s, type, name): + def init_item(self, s: Scenario, type, name): """Initialize an item *name* of *type*. Parameters @@ -591,7 +569,7 @@ def s_init_item(self, s, type, name): """ @abstractmethod - def s_delete_item(self, s, type, name): + def delete_item(self, s: Scenario, type, name): """Remove an item *name* of *type*. Parameters @@ -606,7 +584,7 @@ def s_delete_item(self, s, type, name): """ @abstractmethod - def s_item_index(self, s, name, sets_or_names): + def item_index(self, s: Scenario, name, sets_or_names): """Return the index sets or names of item *name*. Parameters @@ -619,7 +597,7 @@ def s_item_index(self, s, name, sets_or_names): """ @abstractmethod - def s_item_get_elements(self, s, type, name, filters=None): + def item_get_elements(self, s: Scenario, type, name, filters=None): """Return elements of item *name*. Parameters @@ -648,7 +626,7 @@ def s_item_get_elements(self, s, type, name, filters=None): """ @abstractmethod - def s_item_set_elements(self, s, type, name, elements): + def item_set_elements(self, s: Scenario, type, name, elements): """Add keys or values to item *name*. Parameters @@ -687,7 +665,7 @@ def s_item_set_elements(self, s, type, name, elements): """ @abstractmethod - def s_item_delete_elements(self, s, type, name, keys): + def item_delete_elements(self, s: Scenario, type, name, keys): """Remove elements of item *name*. Parameters @@ -708,7 +686,7 @@ def s_item_delete_elements(self, s, type, name, keys): """ @abstractmethod - def s_get_meta(self, s): + def get_meta(self, s: Scenario): """Return all metadata. Returns @@ -722,7 +700,7 @@ def s_get_meta(self, s): """ @abstractmethod - def s_set_meta(self, s, name, value): + def set_meta(self, s: Scenario, name, value): """Set a single metadata key. Parameters @@ -738,7 +716,7 @@ def s_set_meta(self, s, name, value): """ @abstractmethod - def s_clear_solution(self, s, from_year=None): + def clear_solution(self, s: Scenario, from_year=None): """Remove data associated with a model solution. .. todo:: Document. @@ -747,7 +725,7 @@ def s_clear_solution(self, s, from_year=None): # Methods for message_ix.Scenario @abstractmethod - def ms_cat_list(self, ms, name): + def cat_list(self, ms: Scenario, name): """Return list of categories in mapping *name*. Parameters @@ -762,7 +740,7 @@ def ms_cat_list(self, ms, name): """ @abstractmethod - def ms_cat_get_elements(self, ms, name, cat): + def cat_get_elements(self, ms: Scenario, name, cat): """Get elements of a category mapping. Parameters @@ -779,7 +757,7 @@ def ms_cat_get_elements(self, ms, name, cat): """ @abstractmethod - def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): + def cat_set_elements(self, ms: Scenario, name, cat, keys, is_unique): """Add elements to category mapping. Parameters diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 153ff2ed5..4ce98f838 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -13,7 +13,8 @@ from ixmp.config import _config from ixmp.utils import islistable, logger -from ixmp.backend.base import Backend, FIELDS +from . import FIELDS +from .base import Backend # Map of Python to Java log levels @@ -180,7 +181,7 @@ def _common_init(self, ts, klass, *args): # Retrieve initial version ts.version = jobj.getVersion() - def ts_init(self, ts, annotation=None): + def init_ts(self, ts, annotation=None): self._common_init(ts, 'TimeSeries', annotation) def _common_get(self, ts, klass, version): @@ -201,36 +202,36 @@ def _common_get(self, ts, klass, version): else: assert version == jobj.getVersion() - def ts_get(self, ts, version): + def get_ts(self, ts, version): self._common_get(ts, 'TimeSeries', version) - def ts_check_out(self, ts, timeseries_only): + def check_out(self, ts, timeseries_only): self.jindex[ts].checkOut(timeseries_only) - def ts_commit(self, ts, comment): + def commit(self, ts, comment): self.jindex[ts].commit(comment) if ts.version == 0: ts.version = self.jindex[ts].getVersion() - def ts_discard_changes(self, ts): + def discard_changes(self, ts): self.jindex[ts].discardChanges() - def ts_set_as_default(self, ts): + def set_as_default(self, ts): self.jindex[ts].setAsDefaultVersion() - def ts_is_default(self, ts): + def is_default(self, ts): return bool(self.jindex[ts].isDefault()) - def ts_last_update(self, ts): + def last_update(self, ts): return self.jindex[ts].getLastUpdateTimestamp().toString() - def ts_run_id(self, ts): + def run_id(self, ts): return self.jindex[ts].getRunId() - def ts_preload(self, ts): + def preload(self, ts): self.jindex[ts].preloadAllTimeseries() - def ts_get_data(self, ts, region, variable, unit, year): + def get_data(self, ts, region, variable, unit, year): # Convert the selectors to Java lists r = to_jlist2(region) v = to_jlist2(variable) @@ -249,7 +250,7 @@ def ts_get_data(self, ts, region, variable, unit, year): yield tuple(ftype.get(f, str)(row.get(f)) for f in FIELDS['ts_get']) - def ts_get_geo(self, ts): + def get_geo(self, ts): # NB the return type of getGeoData() requires more processing than # getTimeseries. It also accepts no selectors. @@ -291,7 +292,7 @@ def ts_get_geo(self, ts): # Construct a row with a single value yield tuple(cm[f] for f in FIELDS['ts_get_geo']) - def ts_set_data(self, ts, region, variable, data, unit, meta): + def set_data(self, ts, region, variable, data, unit, meta): # Convert *data* to a Java data structure jdata = java.LinkedHashMap() for k, v in data.items(): @@ -301,30 +302,30 @@ def ts_set_data(self, ts, region, variable, data, unit, meta): self.jindex[ts].addTimeseries(region, variable, None, jdata, unit, meta) - def ts_set_geo(self, ts, region, variable, time, year, value, unit, meta): + def set_geo(self, ts, region, variable, time, year, value, unit, meta): self.jindex[ts].addGeoData(region, variable, time, java.Integer(year), value, unit, meta) - def ts_delete(self, ts, region, variable, years, unit): + def delete(self, ts, region, variable, years, unit): years = to_jlist2(years, java.Integer) self.jindex[ts].removeTimeseries(region, variable, None, years, unit) - def ts_delete_geo(self, ts, region, variable, time, years, unit): + def delete_geo(self, ts, region, variable, time, years, unit): years = to_jlist2(years, java.Integer) self.jindex[ts].removeGeoData(region, variable, time, years, unit) # Scenario methods - def s_init(self, s, scheme, annotation): + def init_s(self, s, scheme, annotation): self._common_init(s, 'Scenario', scheme, annotation) - def s_get(self, s, version): + def get_s(self, s, version): self._common_get(s, 'Scenario', version) # Also retrieve the scheme s.scheme = self.jindex[s].getScheme() - def s_clone(self, s, platform_dest, model, scenario, annotation, - keep_solution, first_model_year=None): + def clone(self, s, platform_dest, model, scenario, annotation, + keep_solution, first_model_year=None): # Raise exceptions for limitations of JDBCBackend if not isinstance(platform_dest._backend, self.__class__): raise NotImplementedError(f'Clone between {self.__class__} and' @@ -350,13 +351,13 @@ def s_clone(self, s, platform_dest, model, scenario, annotation, return s.__class__(platform_dest, model, scenario, version=jclone.getVersion(), cache=s._cache) - def s_has_solution(self, s): + def has_solution(self, s): return self.jindex[s].hasSolution() - def s_list_items(self, s, type): + def list_items(self, s, type): return to_pylist(getattr(self.jindex[s], f'get{type.title()}List')()) - def s_init_item(self, s, type, name, idx_sets, idx_names): + def init_item(self, s, type, name, idx_sets, idx_names): # generate index-set and index-name lists if isinstance(idx_sets, set) or isinstance(idx_names, set): raise ValueError('index dimension must be string or ordered lists') @@ -370,14 +371,14 @@ def s_init_item(self, s, type, name, idx_sets, idx_names): # aren't exposed by Backend, so don't return here func(name, idx_sets, idx_names) - def s_delete_item(self, s, type, name): + def delete_item(self, s, type, name): getattr(self.jindex[s], f'remove{type.title()}')() - def s_item_index(self, s, name, sets_or_names): + def item_index(self, s, name, sets_or_names): jitem = self._get_item(s, 'item', name, load=False) return list(getattr(jitem, f'getIdx{sets_or_names.title()}')()) - def s_item_get_elements(self, s, type, name, filters=None): + def item_get_elements(self, s, type, name, filters=None): # Retrieve the item item = self._get_item(s, type, name, load=True) @@ -428,7 +429,7 @@ def s_item_get_elements(self, s, type, name, filters=None): return dict(lvl=item.getScalarLevel().floatValue(), mrg=item.getScalarMarginal().floatValue()) - def s_item_set_elements(self, s, type, name, elements): + def item_set_elements(self, s, type, name, elements): jobj = self._get_item(s, type, name) try: @@ -455,12 +456,12 @@ def s_item_set_elements(self, s, type, name, elements): else: raise RuntimeError('Unhandled Java exception') from e - def s_item_delete_elements(self, s, type, name, keys): + def item_delete_elements(self, s, type, name, keys): jitem = self._get_item(s, type, name, load=False) for key in keys: jitem.removeElement(to_jlist2(key)) - def s_get_meta(self, s): + def get_meta(self, s): def unwrap(v): """Unwrap metadata numeric value (BigDecimal -> Double)""" return v.doubleValue() if isinstance(v, java.BigDecimal) else v @@ -468,10 +469,10 @@ def unwrap(v): return {entry.getKey(): unwrap(entry.getValue()) for entry in self.jindex[s].getMeta().entrySet()} - def s_set_meta(self, s, name, value): + def set_meta(self, s, name, value): self.jindex[s].setMeta(name, value) - def s_clear_solution(self, s, from_year=None): + def clear_solution(self, s, from_year=None): from ixmp.core import Scenario if from_year: @@ -484,23 +485,23 @@ def s_clear_solution(self, s, from_year=None): # MsgScenario methods - def ms_cat_list(self, ms, name): + def cat_list(self, ms, name): return to_pylist(self.jindex[ms].getTypeList(name)) - def ms_cat_get_elements(self, ms, name, cat): + def cat_get_elements(self, ms, name, cat): return to_pylist(self.jindex[ms].getCatEle(name, cat)) - def ms_cat_set_elements(self, ms, name, cat, keys, is_unique): + def cat_set_elements(self, ms, name, cat, keys, is_unique): self.jindex[ms].addCatEle(name, cat, to_jlist2(keys), is_unique) # Helpers; not part of the Backend interface - def s_write_gdx(self, s, path): + def write_gdx(self, s, path): """Write the Scenario to a GDX file at *path*.""" # include_var_equ=False -> do not include variables/equations in GDX self.jindex[s].toGDX(str(path.parent), path.name, False) - def s_read_gdx(self, s, path, check_solution, comment, equ_list, var_list): + def read_gdx(self, s, path, check_solution, comment, equ_list, var_list): """Read the Scenario from a GDX file at *path*. Parameters diff --git a/ixmp/core.py b/ixmp/core.py index 381910c85..c57ce58a0 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -288,9 +288,6 @@ class TimeSeries: annotation : str, optional A short annotation/comment used when ``version='new'``. """ - # Name prefix for Backend methods called through ._backend() - _backend_prefix = 'ts' - #: Name of the model associated with the TimeSeries model = None @@ -310,9 +307,9 @@ def __init__(self, mp, model, scenario, version=None, annotation=None): self.scenario = scenario if version == 'new': - self._backend('init', annotation) + self._backend('init_ts', annotation) elif isinstance(version, int) or version is None: - self._backend('get', version) + self._backend('get_ts', version) else: raise ValueError(f'version={version!r}') @@ -555,9 +552,6 @@ class Scenario(TimeSeries): Store data in memory and return cached values instead of repeatedly querying the backend. """ - # Name prefix for Backend methods called through ._backend() - _backend_prefix = 's' - #: Scheme of the Scenario. scheme = None @@ -572,9 +566,9 @@ def __init__(self, mp, model, scenario, version=None, scheme=None, self.scenario = scenario if version == 'new': - self._backend('init', scheme, annotation) + self._backend('init_s', scheme, annotation) elif isinstance(version, int) or version is None: - self._backend('get', version) + self._backend('get_s', version) else: raise ValueError(f'version={version!r}') diff --git a/ixmp/model/__init__.py b/ixmp/model/__init__.py index 6fda2e72b..b8c4b76bd 100644 --- a/ixmp/model/__init__.py +++ b/ixmp/model/__init__.py @@ -1,12 +1,6 @@ -from .gams import GAMSModel - - #: Mapping from names to available models. To register additional models, #: add elements to this variable. -MODELS = { - 'default': GAMSModel, - 'gams': GAMSModel, -} +MODELS = {} def get_model(name, **model_options): diff --git a/ixmp/model/base.py b/ixmp/model/base.py index b93518822..0ce98c817 100644 --- a/ixmp/model/base.py +++ b/ixmp/model/base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -class Model(ABC): +class Model(ABC): # pragma: no cover #: Name of the model. name = 'base' From c6f60b8aa367206de600e9024afd2f1bb27075c6 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 31 Oct 2019 13:54:39 +0100 Subject: [PATCH 71/71] Combine Backend.get_ts and .get_s --- doc/source/api-backend.rst | 3 +-- ixmp/backend/base.py | 30 +++++------------------------- ixmp/backend/jdbc.py | 17 +++++++---------- ixmp/core.py | 4 ++-- 4 files changed, 15 insertions(+), 39 deletions(-) diff --git a/doc/source/api-backend.rst b/doc/source/api-backend.rst index 84e3a40f9..43840882f 100644 --- a/doc/source/api-backend.rst +++ b/doc/source/api-backend.rst @@ -99,7 +99,7 @@ Backend API delete delete_geo discard_changes - get_ts + get get_data get_geo init_ts @@ -120,7 +120,6 @@ Backend API clone delete_item - get_s get_meta has_solution init_s diff --git a/ixmp/backend/base.py b/ixmp/backend/base.py index 3bde2defe..a0d8f49fc 100644 --- a/ixmp/backend/base.py +++ b/ixmp/backend/base.py @@ -211,12 +211,15 @@ def init_ts(self, ts: TimeSeries, annotation=None): """ @abstractmethod - def get_ts(self, ts: TimeSeries, version): - """Retrieve the existing TimeSeries *ts*. + def get(self, ts: TimeSeries, version): + """Retrieve the existing TimeSeries or Scenario *ts*. The TimeSeries is identified based on its (:attr:`~.TimeSeries.model`, :attr:`~.TimeSeries.scenario`) and *version*. + If *ts* is a Scenario, :meth:`get` **must** set the + :attr:`~.Scenario.scheme` attribute on it. + Parameters ---------- version : str or None @@ -483,29 +486,6 @@ def init_s(self, s: Scenario, scheme, annotation): None """ - @abstractmethod - def get_s(self, s: Scenario, version): - """Retrieve the existing Scenario *s*. - - The Scenario is identified based on its (:attr:`~.TimeSeries.model`, - :attr:`~.TimeSeries.scenario`) and *version*. s_get **must** set - the :attr:`.Scenario.scheme` attribute on *s*. - - Parameters - ---------- - version : str or None - If :obj:`None`, the version marked as the default is returned, and - s_get **must** set :attr:`.TimeSeries.version` attribute on *s*. - - Returns - ------- - None - - See also - -------- - ts_set_as_default - """ - @abstractmethod def clone(self, s: Scenario, platform_dest, model, scenario, annotation, keep_solution, first_model_year=None): diff --git a/ixmp/backend/jdbc.py b/ixmp/backend/jdbc.py index 4ce98f838..1d19b88db 100644 --- a/ixmp/backend/jdbc.py +++ b/ixmp/backend/jdbc.py @@ -12,6 +12,7 @@ import pandas as pd from ixmp.config import _config +from ixmp.core import Scenario from ixmp.utils import islistable, logger from . import FIELDS from .base import Backend @@ -184,14 +185,14 @@ def _common_init(self, ts, klass, *args): def init_ts(self, ts, annotation=None): self._common_init(ts, 'TimeSeries', annotation) - def _common_get(self, ts, klass, version): - """Common code for ts_get and s_get.""" + def get(self, ts, version): args = [ts.model, ts.scenario] if isinstance(version, int): # Load a TimeSeries of specific version args.append(version) - method = getattr(self.jobj, 'get' + klass) + # either getTimeSeries or getScenario + method = getattr(self.jobj, 'get' + ts.__class__.__name__) jobj = method(*args) # Add to index self.jindex[ts] = jobj @@ -202,8 +203,9 @@ def _common_get(self, ts, klass, version): else: assert version == jobj.getVersion() - def get_ts(self, ts, version): - self._common_get(ts, 'TimeSeries', version) + if isinstance(ts, Scenario): + # Also retrieve the scheme + ts.scheme = jobj.getScheme() def check_out(self, ts, timeseries_only): self.jindex[ts].checkOut(timeseries_only) @@ -319,11 +321,6 @@ def delete_geo(self, ts, region, variable, time, years, unit): def init_s(self, s, scheme, annotation): self._common_init(s, 'Scenario', scheme, annotation) - def get_s(self, s, version): - self._common_get(s, 'Scenario', version) - # Also retrieve the scheme - s.scheme = self.jindex[s].getScheme() - def clone(self, s, platform_dest, model, scenario, annotation, keep_solution, first_model_year=None): # Raise exceptions for limitations of JDBCBackend diff --git a/ixmp/core.py b/ixmp/core.py index c57ce58a0..3e324df10 100644 --- a/ixmp/core.py +++ b/ixmp/core.py @@ -309,7 +309,7 @@ def __init__(self, mp, model, scenario, version=None, annotation=None): if version == 'new': self._backend('init_ts', annotation) elif isinstance(version, int) or version is None: - self._backend('get_ts', version) + self._backend('get', version) else: raise ValueError(f'version={version!r}') @@ -568,7 +568,7 @@ def __init__(self, mp, model, scenario, version=None, scheme=None, if version == 'new': self._backend('init_s', scheme, annotation) elif isinstance(version, int) or version is None: - self._backend('get_s', version) + self._backend('get', version) else: raise ValueError(f'version={version!r}')