diff --git a/.travis.yml b/.travis.yml index e4b5e13a3..f984da8b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,20 @@ language: python -dist: xenial + +dist: trusty + +compiler: + - gcc + matrix: include: - python: '2.7' - python: '3.5' - python: '3.6' - - python: '3.7' + # - python: '3.7' # Missing working compile setup for sunbeam before_install: - python --version + - export CXX="g++-4.9" CC="gcc-4.9" - export INSTALL_DIR=`pwd`/../install - export PYTHONPATH=$INSTALL_DIR/lib/python$TRAVIS_PYTHON_VERSION/dist-packages:$PYTHONPATH - export LD_LIBRARY_PATH=$INSTALL_DIR/lib:$INSTALL_DIR/lib64:$LD_LIBRARY_PATH @@ -16,14 +22,27 @@ before_install: - echo $PYTHONPATH - echo $LD_LIBRARY_PATH +addons: + apt: + sources: + - boost-latest + - ubuntu-toolchain-r-test + packages: + - gcc-4.9 + - g++-4.9 + - libboost1.55-all-dev + - liblapack-dev + +sudo: required + install: - python -m pip install --upgrade pip + - python -m pip install --upgrade -r requirements.txt - python -m pip install --upgrade -r requirements-dev.txt before_script: # For now we have to make install libecl. # Remove when it's possible to pip install - - source .libecl_version - git clone https://github.com/equinor/libecl - pushd libecl @@ -45,6 +64,27 @@ before_script: - rm -rf libecl - python -c "import ecl; print(ecl.__file__)" + # We also need sunbeam, which requires opm-common: + - git clone --recursive https://github.com/OPM/opm-common.git + - git clone --recursive https://github.com/equinor/sunbeam.git + - mkdir opm-common/build + - pushd opm-common/build + - git checkout release/sunbeam/2019.02 + - cmake .. -DCMAKE_PREFIX_PATH=$INSTALL_DIR + -DCMAKE_INSTALL_PREFIX=$INSTALL_DIR + -DBUILD_TESTING=OFF + -DBUILD_SHARED_LIBS=ON + - make -j 4 install + - popd + - mkdir sunbeam/build + - pushd sunbeam/build + - cmake .. -DCMAKE_PREFIX_PATH=$INSTALL_DIR + -DUSE_RPATH=ON + -DCMAKE_INSTALL_PREFIX=$INSTALL_DIR + -DPYTHON_EXECUTABLE=`which python` + - make -j 4 install + - popd + script: - python setup.py test - python -m flake8 subscript diff --git a/requirements-dev.txt b/requirements-dev.txt index 571f3b042..66b1238c3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ pandas scipy +pyyaml>=5.1 setuptools >=28 setuptools_scm pytest diff --git a/requirements.txt b/requirements.txt index 6e8dab0da..b97ccf726 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pandas scipy +pyyaml>=5.1 diff --git a/setup.cfg b/setup.cfg index acd7cd689..89ce7b1e7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,8 @@ norecursedirs = .env dist build + opm-common + sunbeam addopts = -ra diff --git a/setup.py b/setup.py index 7c439e4cf..62ba0e7d8 100644 --- a/setup.py +++ b/setup.py @@ -21,8 +21,8 @@ tests_require=["pytest"], entry_points={ "console_scripts": [ - "subscript = subscript.cli:main", "presentvalue = subscript.presentvalue:main", + "sunsch = subscript.sunsch:main", ] }, use_scm_version={"write_to": "subscript/version.py"}, diff --git a/subscript/sunsch.py b/subscript/sunsch.py new file mode 100644 index 000000000..32c9b7bca --- /dev/null +++ b/subscript/sunsch.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +""" +Tool for generating Eclipse Schedule files +""" + +import datetime +import tempfile +import argparse +import yaml +from sunbeam.tools import TimeVector + + +def datetime_from_date(date): + """Set time to 00:00:00 in a date""" + return datetime.datetime.combine(date, datetime.datetime.min.time()) + + +def process_sch_config(sunschconf, quiet=True): + """Process a Schedule configuration into a sunbeam TimeVector + + :param sunschconf : configuration for the schedule merges and inserts + :type sunschconf: dict + :param quiet: Whether status messages should be printed during processing + :type quiet: bool + """ + if "startdate" in sunschconf: + schedule = TimeVector(sunschconf["startdate"]) + elif "refdate" in sunschconf: + schedule = TimeVector(sunschconf["refdate"]) + else: + raise Exception("No startdate or refdate given") + + if "refdate" not in sunschconf and "startdate" in sunschconf: + sunschconf["refdate"] = sunschconf["startdate"] + + if "init" in sunschconf: + if not quiet: + print("Loading " + sunschconf["init"] + " at startdate") + schedule.load( + sunschconf["init"], + date=datetime.datetime.combine( + sunschconf["startdate"], datetime.datetime.min.time() + ), + ) + + if "merge" in sunschconf: + for filename in sunschconf["merge"]: + try: + if not quiet: + print("Loading " + filename) + schedule.load(filename) + except ValueError as exception: + raise Exception("Error in " + filename + ": " + str(exception)) + + if "insert" in sunschconf: # inserts should be list of dicts of dicts + for filedict in sunschconf["insert"]: + # filedict is now a dict with only one key + fileid = list(filedict.keys())[0] + filedata = list(filedict[fileid].keys()) + + # Figure out the correct filename, only needed when we + # have a string. + if "string" not in filedata: + if "filename" not in filedata: + filename = fileid + else: + filename = filedict[fileid]["filename"] + + resultfile = tempfile.NamedTemporaryFile(mode="w", delete=False) + resultfilename = resultfile.name + if "substitute" in filedata: + templatelines = open(filename, "r").readlines() + + # Parse substitution list: + substdict = filedict[fileid]["substitute"] + # Perform substitution and put into a tmp file + for line in templatelines: + for key in substdict: + if "<" + key + ">" in line: + line = line.replace("<" + key + ">", str(substdict[key])) + resultfile.write(line) + resultfile.close() + # Now we overwrite the filename coming from the yaml file! + filename = resultfilename + + # Figure out the correct date: + if "date" in filedict[fileid]: + date = datetime.datetime.combine( + filedict[fileid]["date"], datetime.datetime.min.time() + ) + if "days" in filedict[fileid]: + if "refdate" not in sunschconf: + raise Exception( + "ERROR: When using days in insert " + + "statements, you must provide refdate" + ) + date = datetime.datetime.combine( + sunschconf["refdate"], datetime.datetime.min.time() + ) + datetime.timedelta(days=filedict[fileid]["days"]) + if "string" not in filedata: + schedule.load(filename, date=date) + else: + schedule.add_keywords( + datetime_from_date(date), [filedict[fileid]["string"]] + ) + + if "enddate" not in sunschconf: + if not quiet: + print( + ("Warning: Implicit end date. " + "Any content at last date is ignored") + ) + # Whether we include it in the output does not matter, + # Eclipse will ignore it + enddate = schedule.dates[-1].date() + else: + enddate = sunschconf["enddate"] # datetime.date + if not isinstance(enddate, datetime.date): + raise Exception( + "ERROR: end-date not in ISO-8601 format, must be YYYY-MM-DD" + ) + + # Clip anything that is beyond the enddate + for date in schedule.dates: + if date.date() > enddate: + schedule.delete(date) + + # Ensure that the end-date is actually mentioned in the Schedule + # so that we know Eclipse will actually simulate until this date + if enddate not in [x.date() for x in schedule.dates]: + schedule.add_keywords(datetime_from_date(enddate), [""]) + + # Dategrid is added at the end, in order to support + # an implicit end-date + if "dategrid" in sunschconf: + dates = dategrid(sunschconf["startdate"], enddate, sunschconf["dategrid"]) + for date in dates: + schedule.add_keywords(datetime_from_date(date), [""]) + + return schedule + + +def dategrid(startdate, enddate, interval): + """Return a list of datetimes at given interval + + + Parameters + ---------- + startdate: datetime.date + First date in range + enddate: datetime.date + Last date in range + interval: str + Must be among: 'monthly', 'yearly', 'weekly', + 'biweekly', 'bimonthly' + Return + ------ + list of datetime.date. Includes start-date, might not include end-date + """ + + supportedintervals = ["monthly", "yearly", "weekly", "biweekly", "bimonthly"] + if interval not in supportedintervals: + raise Exception( + 'Unsupported dategrid interval "' + + interval + + '". Pick among ' + + ", ".join(supportedintervals) + ) + dates = [startdate] + date = startdate + datetime.timedelta(days=1) + startdateweekday = startdate.weekday() + + # Brute force implementation by looping over all possible + # days. This is robust with respect to all possible date oddities, + # but makes it difficult to support more interval types. + while date <= enddate: + if interval == "monthly": + if date.day == 1: + dates.append(date) + elif interval == "bimonthly": + if date.day == 1 and date.month % 2 == 1: + dates.append(date) + elif interval == "weekly": + if date.weekday() == startdateweekday: + dates.append(date) + elif interval == "biweekly": + weeknumber = date.isocalendar()[1] + if date.weekday() == startdateweekday and weeknumber % 2 == 1: + dates.append(date) + elif interval == "yearly": + if date.day == 1 and date.month == 1: + dates.append(date) + elif interval == "daily": + dates.append(date) + date += datetime.timedelta(days=1) + return dates + + +# If we are called from command line: +def get_parser(): + """Set up parser for command line utility""" + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""Generate Eclipse Schedule file from merges and insertions. + +Reads a YAML-file specifying how a Eclipse Schedule section is to be +produced given certain input files. + +Output will not be generated unless the produced data is valid in + Eclipse, checking provided by sunbeam/opm-parser.""", + epilog="""YAML-file components: + + init - filename for the initial file. If omitted, defaults to an + empty file. If you need something to happen between the + Eclipse start date and the first DATES keyword, it must + be present in this file. + + output - filename for output. stdout if omitted + + startdate - YYYY-MM-DD for the initial date in the simulation. There + should not be any events before this date in merged-in files. + TODO: Clip any events before startdate + + refdate - if supplied, will work as a reference date for relative + inserts. If not supplied startdate will be used. + + enddate - YYYY-MM-DD, anything after that date will be clipped (TODO). + + dategrid - a string being either 'weekly', 'biweekly', 'monthly', + 'bimonthly' stating how often a DATES keyword is wanted + (independent of inserts/merges). '(bi)monthly' and + 'yearly' will be rounded to first in every month. + + merge - list of filenames to be merged in. DATES must be the first + keyword in these files. + + insert - list of components to be inserted into the final Schedule + file. Each list elemen can contain the elemens: + + date - Fixed date for the insertion + + days - relative date for insertion relative to refdate/startdate + + filename - filename to override the yaml-component element name. + + string - instead of filename, you can write the contents inline + + substitute - key-value pairs that will subsitute in + incoming files (or inline string) with + associated values. + """, + ) + parser.add_argument( + "config", help="Config file in YAML format for Schedule merging" + ) + parser.add_argument( + "-o", + "--output", + type=str, + default="", + help="Override output in yaml config. Use - for stdout", + ) + parser.add_argument( + "-q", "--quiet", action="store_true", help="Mute output from script" + ) + return parser + + +def main(): + """Entry point from command line""" + parser = get_parser() + args = parser.parse_args() + + # Load YAML file: + config = yaml.safe_load(open(args.config)) + + # Overrides: + if args.output != "": + config["output"] = args.output + + if "output" not in config: + config["output"] = "-" # Write to stdout + + if args.output == "-": + args.quiet = True + + schedule = process_sch_config(config, args.quiet) + + if config["output"] == "-" or "output" not in config: + print(str(schedule)) + else: + if not args.quiet: + print("Writing Eclipse deck to " + config["output"]) + open(config["output"], "w").write(str(schedule)) + + +if __name__ == "__main__": + main() diff --git a/subscript/tests/test_sunsch.py b/subscript/tests/test_sunsch.py new file mode 100644 index 000000000..e61779763 --- /dev/null +++ b/subscript/tests/test_sunsch.py @@ -0,0 +1,38 @@ +from __future__ import absolute_import + +import pytest # noqa: F401 +import os +import sys + +from .. import sunsch + + +def test_main(): + """Test command line sunsch, loading a yaml file""" + os.chdir(os.path.join(os.path.dirname(__file__), "testdata_sunsch")) + + outfile = "schedule.sch" # also in config.yml + + if os.path.exists(outfile): + os.unlink(outfile) + sys.argv = ["sunsch", "config.yml"] + sunsch.main() + assert os.path.exists(outfile) + + schlines = open(outfile).readlines() + assert len(schlines) > 70 + + # Check footemplate.sch was included: + assert any(['A-90' in x for x in schlines]) + + # Sample check for mergeme.sch: + assert any(['WRFTPLT' in x for x in schlines]) + + # Check for foo1.sch, A-1 should occur twice + assert sum(['A-1' in x for x in schlines]) == 2 + + # Check for substitutetest: + assert any(['400000' in x for x in schlines]) + + # Check for randomid: + assert any(['A-4' in x for x in schlines]) diff --git a/subscript/tests/testdata_sunsch/config.yml b/subscript/tests/testdata_sunsch/config.yml new file mode 100644 index 000000000..5dd694eb0 --- /dev/null +++ b/subscript/tests/testdata_sunsch/config.yml @@ -0,0 +1,28 @@ +init: emptyinit.sch +output: schedule.sch +startdate: 2017-01-01 +#refdate: 2017-01-01 +enddate: 2020-12-01 +merge: + - mergeme.sch +dategrid: yearly +insert: + - foo1.sch: # filename is read from this line unless filename is supplied + date: 2020-01-01 + - randomidentifier: + filename: foo1.sch + date: 2021-01-01 + - foo1.sch: + days: 100 + - randomid: + days: 40 + string: "WCONHIST\n A-4 OPEN ORAT 5000 /\n/" + - substitutetest: + days: 2 + filename: footemplate.sch + substitute: { ORAT: 3000, GRAT: 400000} + - footemplate.sch: + days: 10 + substitute: + ORAT: 30000 + GRAT: 100 diff --git a/subscript/tests/testdata_sunsch/emptyinit.sch b/subscript/tests/testdata_sunsch/emptyinit.sch new file mode 100644 index 000000000..006b792f2 --- /dev/null +++ b/subscript/tests/testdata_sunsch/emptyinit.sch @@ -0,0 +1,4 @@ +-- This file is intentionally empty. Where does this comment go? +WCONHIST + 'BAR-FOO' 'SHUT'/ +/ diff --git a/subscript/tests/testdata_sunsch/foo1.sch b/subscript/tests/testdata_sunsch/foo1.sch new file mode 100644 index 000000000..2b32b0941 --- /dev/null +++ b/subscript/tests/testdata_sunsch/foo1.sch @@ -0,0 +1,5 @@ +-- From foo1.sch +WCONHIST +-- from foo1.sch + 'A-1' 'OPEN' 'ORAT' 5000 / +/ diff --git a/subscript/tests/testdata_sunsch/footemplate.sch b/subscript/tests/testdata_sunsch/footemplate.sch new file mode 100644 index 000000000..9e22da797 --- /dev/null +++ b/subscript/tests/testdata_sunsch/footemplate.sch @@ -0,0 +1,3 @@ +WCONHIST + 'A-90' 'OPEN' 'ORAT' / +/ diff --git a/subscript/tests/testdata_sunsch/mergeme.sch b/subscript/tests/testdata_sunsch/mergeme.sch new file mode 100644 index 000000000..a964129d3 --- /dev/null +++ b/subscript/tests/testdata_sunsch/mergeme.sch @@ -0,0 +1,14 @@ +DATES + 9 'FEB' 2019 / +/ + +WCONHIST + 'A-2' 'OPEN' 'ORAT' 4000 / +/ +DATES + 1 'OCT' 2020 / +/ + +WRFTPLT + 'NO' / +/ \ No newline at end of file