mirror of
https://github.com/OPM/ResInsight.git
synced 2026-08-27 13:47:12 -05:00
1852 lines
73 KiB
Python
1852 lines
73 KiB
Python
"""
|
|
Tests for the ORIONEVENTS parser and applier (rips.orion_events).
|
|
|
|
The parser tests (Layer A) are pure Python and need no running ResInsight.
|
|
The applier tests (Layer B) drive the mapping logic against a fake timeline and
|
|
fake project, so they also run without an instance.
|
|
|
|
TestOrionEventsIntegration drives the real WellEventTimeline API and therefore
|
|
needs a running ResInsight (the shared instance from conftest.py); it is skipped
|
|
automatically when no instance is available.
|
|
"""
|
|
|
|
import datetime
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
import rips # noqa: E402
|
|
import dataroot # noqa: E402
|
|
from rips.orion_events import ( # noqa: E402
|
|
ApplyReport,
|
|
OrionParseError,
|
|
_cli,
|
|
apply_orion_document,
|
|
coalesce_orion_document,
|
|
parse_orion_events,
|
|
)
|
|
|
|
SAMPLE = """\
|
|
# A comment line
|
|
ORIONEVENTS 2.0
|
|
UNIT METRIC
|
|
|
|
DATE A1_STARTUP = 2018-01-01
|
|
DATE A2_STARTUP = 2018-03-01 + 9
|
|
DURATION RAMP = 5 DAYS
|
|
|
|
WELL A1 = "55_33-A-1"
|
|
|
|
WELL A1
|
|
@A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
|
|
@A1_STARTUP + RAMP WCONHIST STATUS=OPEN CMODE=ORAT VFP=1
|
|
@A1_STARTUP + RAMP WELTARG CMODE=BHP VALUE=50
|
|
|
|
WELL "55_33-A-2"
|
|
@A2_STARTUP PERFORATION MDSTART=1692.79 MDEND=1706 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fakes for the applier tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class FakeCompletionSettings:
|
|
def __init__(self):
|
|
self.group_name_for_export = "FIELD"
|
|
self.allow_well_cross_flow = True
|
|
self.reference_depth_for_export = None
|
|
self.well_type_for_export = "OIL"
|
|
|
|
|
|
class FakeWellPath:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
self._completion_settings = FakeCompletionSettings()
|
|
|
|
def completion_settings(self):
|
|
return self._completion_settings
|
|
|
|
|
|
class FakeProject:
|
|
def __init__(self, names, cases=()):
|
|
self._wells = {name: FakeWellPath(name) for name in names}
|
|
self._cases = list(cases)
|
|
|
|
def well_path_by_name(self, name):
|
|
return self._wells.get(name)
|
|
|
|
def cases(self):
|
|
return self._cases
|
|
|
|
|
|
class FakePropertyFilter:
|
|
"""Bounds default to sentinel 'result range' values, as setToDefaultValues does."""
|
|
|
|
def __init__(self, result_variable, result_type):
|
|
self.result_variable = result_variable
|
|
self.result_type = result_type
|
|
self.lower_bound = -1.0e20
|
|
self.upper_bound = 1.0e20
|
|
self.update_calls = 0
|
|
|
|
def update(self):
|
|
self.update_calls += 1
|
|
|
|
|
|
class FakeCombinedFilter:
|
|
def __init__(self, name, combine_mode):
|
|
self.name = name
|
|
self.combine_mode = combine_mode
|
|
self.property_filters = []
|
|
|
|
def add_property_filter(self, result_variable, result_type):
|
|
property_filter = FakePropertyFilter(result_variable, result_type)
|
|
self.property_filters.append(property_filter)
|
|
return property_filter
|
|
|
|
|
|
class FakeDataFilterCollection:
|
|
def __init__(self):
|
|
self.combined_filters = []
|
|
|
|
def add_combined_filter(self, name="", combine_mode="AND"):
|
|
combined = FakeCombinedFilter(name, combine_mode)
|
|
self.combined_filters.append(combined)
|
|
return combined
|
|
|
|
|
|
class FakeCase:
|
|
"""A case with canned per-type result names and a data filter collection."""
|
|
|
|
_PROPERTIES = {
|
|
"STATIC_NATIVE": ["PORO", "PERMX"],
|
|
"DYNAMIC_NATIVE": ["SOIL", "PRESSURE", "PORO"],
|
|
"GENERATED": [],
|
|
}
|
|
|
|
def __init__(self):
|
|
self._data_filter_collection = FakeDataFilterCollection()
|
|
|
|
def available_properties(self, property_type):
|
|
return self._PROPERTIES[property_type]
|
|
|
|
def data_filter_collection(self):
|
|
return self._data_filter_collection
|
|
|
|
|
|
class FakeTimelineEvent:
|
|
def __init__(self):
|
|
self.comment = ""
|
|
self.update_calls = 0
|
|
|
|
def update(self):
|
|
self.update_calls += 1
|
|
|
|
|
|
class FakePerfEvent(FakeTimelineEvent):
|
|
"""The object returned by add_perf_event; records attached filters."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.filters = []
|
|
|
|
def add_filter(self, filter):
|
|
self.filters.append(filter)
|
|
|
|
|
|
class FakeTimeline:
|
|
"""Records calls made by the applier instead of contacting ResInsight."""
|
|
|
|
def __init__(self):
|
|
self.perf_calls = []
|
|
self.perf_events = []
|
|
self.keyword_calls = []
|
|
self.tubing_calls = []
|
|
self.valve_calls = []
|
|
self.state_calls = []
|
|
self.wellspec_calls = []
|
|
self.schedule_keyword_calls = []
|
|
self.created_events = []
|
|
|
|
def add_perf_event(self, **kwargs):
|
|
self.perf_calls.append(kwargs)
|
|
perf_event = FakePerfEvent()
|
|
self.perf_events.append(perf_event)
|
|
return perf_event
|
|
|
|
def _new_event(self):
|
|
event = FakeTimelineEvent()
|
|
self.created_events.append(event)
|
|
return event
|
|
|
|
def add_well_keyword_event(self, **kwargs):
|
|
self.keyword_calls.append(kwargs)
|
|
return self._new_event()
|
|
|
|
def add_tubing_event(self, **kwargs):
|
|
self.tubing_calls.append(kwargs)
|
|
return self._new_event()
|
|
|
|
def add_valve_event(self, **kwargs):
|
|
self.valve_calls.append(kwargs)
|
|
return self._new_event()
|
|
|
|
def add_state_event(self, **kwargs):
|
|
self.state_calls.append(kwargs)
|
|
return self._new_event()
|
|
|
|
def add_wellspec_event(self, **kwargs):
|
|
self.wellspec_calls.append(kwargs)
|
|
return self._new_event()
|
|
|
|
def add_keyword_event(self, **kwargs):
|
|
self.schedule_keyword_calls.append(kwargs)
|
|
return self._new_event()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layer A: parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParsing:
|
|
def test_header_and_unit(self):
|
|
doc = parse_orion_events(SAMPLE)
|
|
assert doc.version == "2.0"
|
|
assert doc.unit_system == "METRIC"
|
|
|
|
def test_date_declarations_with_day_offset(self):
|
|
doc = parse_orion_events(SAMPLE)
|
|
assert doc.variables["A1_STARTUP"].kind == "DATE"
|
|
assert doc.variables["A1_STARTUP"].value == datetime.date(2018, 1, 1)
|
|
# 2018-03-01 + 9 days
|
|
assert doc.variables["A2_STARTUP"].value == datetime.date(2018, 3, 10)
|
|
|
|
def test_duration_and_well_declarations(self):
|
|
doc = parse_orion_events(SAMPLE)
|
|
assert doc.variables["RAMP"].kind == "DURATION"
|
|
assert doc.variables["RAMP"].value == 5
|
|
assert doc.variables["A1"].kind == "WELL"
|
|
assert doc.variables["A1"].value == "55_33-A-1"
|
|
|
|
def test_well_blocks_and_event_dates(self):
|
|
doc = parse_orion_events(SAMPLE)
|
|
assert [w.well_name for w in doc.wells] == ["55_33-A-1", "55_33-A-2"]
|
|
well1 = doc.wells[0]
|
|
assert [e.event_type for e in well1.events] == [
|
|
"PERFORATION",
|
|
"WCONHIST",
|
|
"WELTARG",
|
|
]
|
|
# @A1_STARTUP + RAMP -> 2018-01-06
|
|
assert well1.events[1].event_date == datetime.date(2018, 1, 6)
|
|
|
|
def test_value_type_inference(self):
|
|
doc = parse_orion_events(SAMPLE)
|
|
perf = doc.wells[0].events[0]
|
|
assert perf.attributes["MDSTART"].value == 1644.49 # float
|
|
assert perf.attributes["COMPLETION_NUMBER"].value == 1 # int
|
|
assert perf.attributes["MDEND"].value == 1664.28
|
|
wconhist = doc.wells[0].events[1]
|
|
assert wconhist.attributes["STATUS"].value == "OPEN" # string
|
|
|
|
def test_quoted_filter_value_is_single_attribute(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
' @2018-01-01 PERFORATION MDSTART=1 MDEND=2 FILTER="SOIL > 0.8 AND PERMX > 200"\n'
|
|
)
|
|
doc = parse_orion_events(text)
|
|
filter_attr = doc.wells[0].events[0].attributes["FILTER"]
|
|
assert filter_attr.value == "SOIL > 0.8 AND PERMX > 200"
|
|
assert filter_attr.quoted is True
|
|
assert doc.wells[0].events[0].attributes["MDEND"].value == 2
|
|
|
|
def test_comment_attribute_is_preserved(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
' @2018-01-01 WCONHIST STATUS=OPEN COMMENT="Startup target"\n'
|
|
)
|
|
event = parse_orion_events(text).wells[0].events[0]
|
|
assert event.attributes["COMMENT"].value == "Startup target"
|
|
assert event.attributes["COMMENT"].quoted is True
|
|
|
|
def test_trailing_comment_ignored_but_not_inside_quotes(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
' @2018-01-01 WCONHIST NOTE="a # b" CMODE=ORAT # trailing comment\n'
|
|
)
|
|
doc = parse_orion_events(text)
|
|
attrs = doc.wells[0].events[0].attributes
|
|
assert attrs["NOTE"].value == "a # b"
|
|
assert "CMODE" in attrs
|
|
|
|
def test_iso_date_literal_event(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n @2020-12-31 PERFORATION MDSTART=1 MDEND=2\n'
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].events[0].event_date == datetime.date(2020, 12, 31)
|
|
|
|
def test_negative_offset(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nDATE START = 2018-01-10\n"
|
|
'WELL "W"\n @START - 5 PERFORATION MDSTART=1 MDEND=2\n'
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].events[0].event_date == datetime.date(2018, 1, 5)
|
|
|
|
def test_minus_after_iso_date(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL "W"\n @2018-01-01 - 5 PERFORATION MDSTART=1 MDEND=2\n'
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].events[0].event_date == datetime.date(2017, 12, 27)
|
|
|
|
def test_offset_chain_with_duration_variable(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nDATE START = 2018-01-01\nDURATION RAMP = 5\n"
|
|
'WELL "W"\n @START + RAMP - 2 PERFORATION MDSTART=1 MDEND=2\n'
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].events[0].event_date == datetime.date(2018, 1, 4)
|
|
|
|
def test_duration_declaration_days_suffix_optional(self):
|
|
for suffix in ("", " DAYS", " days"):
|
|
doc = parse_orion_events(f"ORIONEVENTS 2.0\nDURATION X = 5{suffix}\n")
|
|
assert doc.variables["X"].value == 5
|
|
|
|
def test_duration_arithmetic_in_declaration(self):
|
|
text = "ORIONEVENTS 2.0\nDURATION RAMP = 5\nDURATION X = RAMP + 2 DAYS\n"
|
|
doc = parse_orion_events(text)
|
|
assert doc.variables["X"].value == 7
|
|
|
|
def test_well_alias_resolution(self):
|
|
doc = parse_orion_events(SAMPLE)
|
|
# 'WELL A1' block resolves to the declared alias target.
|
|
assert doc.wells[0].well_name == "55_33-A-1"
|
|
|
|
def test_quoted_well_block_ignores_alias(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL A1 = "55_33-A-1"\nWELL "A1"\n'
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].well_name == "A1"
|
|
|
|
def test_unknown_well_alias_raises(self):
|
|
with pytest.raises(OrionParseError, match="Unknown variable 'NOPE'"):
|
|
parse_orion_events("ORIONEVENTS 2.0\nWELL NOPE\n")
|
|
|
|
def test_empty_well_block_ok(self):
|
|
doc = parse_orion_events('ORIONEVENTS 2.0\nWELL "W"\n')
|
|
assert doc.wells[0].well_name == "W"
|
|
assert doc.wells[0].events == []
|
|
|
|
def test_v1_file_rejected_with_clear_message(self):
|
|
with pytest.raises(OrionParseError, match="no longer supported"):
|
|
parse_orion_events("ORIONEVENTS 1.0\nUNIT METRIC\n")
|
|
|
|
def test_unsupported_version_rejected(self):
|
|
with pytest.raises(OrionParseError, match="Unsupported ORIONEVENTS version"):
|
|
parse_orion_events("ORIONEVENTS 3.0\n")
|
|
|
|
def test_missing_header_raises(self):
|
|
with pytest.raises(OrionParseError):
|
|
parse_orion_events('UNIT METRIC\nWELL "W"\n')
|
|
|
|
def test_event_before_well_block_raises(self):
|
|
with pytest.raises(OrionParseError, match="before any WELL or SCHEDULE block"):
|
|
parse_orion_events(
|
|
"ORIONEVENTS 2.0\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
|
|
def test_unknown_variable_raises(self):
|
|
with pytest.raises(OrionParseError, match="Unknown variable 'NOPE'"):
|
|
parse_orion_events(
|
|
'ORIONEVENTS 2.0\nWELL "W"\n @NOPE PERFORATION MDSTART=1 MDEND=2\n'
|
|
)
|
|
|
|
def test_duration_where_date_expected_raises(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nDURATION RAMP = 5\n"
|
|
'WELL "W"\n @RAMP WCONHIST STATUS=OPEN\n'
|
|
)
|
|
with pytest.raises(
|
|
OrionParseError, match=r"is a DURATION \(declared line 2\) but a DATE"
|
|
):
|
|
parse_orion_events(text)
|
|
|
|
def test_date_where_duration_expected_raises(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nDATE START = 2018-01-01\nDATE OTHER = 2018-02-01\n"
|
|
'WELL "W"\n @START + OTHER WCONHIST STATUS=OPEN\n'
|
|
)
|
|
with pytest.raises(OrionParseError, match="is a DATE .* but a DURATION"):
|
|
parse_orion_events(text)
|
|
|
|
def test_well_variable_in_date_context_raises(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL A1 = "X"\nWELL A1\n @A1 WCONHIST STATUS=OPEN\n'
|
|
with pytest.raises(OrionParseError, match="is a WELL .* but a DATE"):
|
|
parse_orion_events(text)
|
|
|
|
def test_cross_type_redefinition_raises(self):
|
|
text = "ORIONEVENTS 2.0\nDATE X = 2018-01-01\nDURATION X = 5\n"
|
|
with pytest.raises(
|
|
OrionParseError, match="already declared as DATE .* redeclare as DURATION"
|
|
):
|
|
parse_orion_events(text)
|
|
|
|
def test_forward_reference_raises(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n @START PERFORATION MDSTART=1 MDEND=2\n'
|
|
"DATE START = 2018-01-01\n"
|
|
)
|
|
with pytest.raises(OrionParseError, match="Unknown variable 'START'"):
|
|
parse_orion_events(text)
|
|
|
|
def test_single_quoted_well_name_rejected(self):
|
|
text = "ORIONEVENTS 2.0\n'W'\n"
|
|
with pytest.raises(OrionParseError, match="1.x syntax"):
|
|
parse_orion_events(text)
|
|
|
|
def test_set_line_rejected_with_hint(self):
|
|
text = "ORIONEVENTS 2.0\nSET X = 2018-01-01\n"
|
|
with pytest.raises(OrionParseError, match="SET is ORIONEVENTS 1.x syntax"):
|
|
parse_orion_events(text)
|
|
|
|
def test_malformed_attribute_raises(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL "W"\n @2018-01-01 PERFORATION MDSTART=1 bogus MDEND=2\n'
|
|
with pytest.raises(OrionParseError, match="Malformed attribute"):
|
|
parse_orion_events(text)
|
|
|
|
def test_duplicate_date_declaration_warns(self):
|
|
text = 'ORIONEVENTS 2.0\nDATE X = 2018-01-01\nDATE X = 2019-01-01\nWELL "W"\n'
|
|
doc = parse_orion_events(text)
|
|
assert any("Duplicate" in w.message for w in doc.warnings)
|
|
assert doc.variables["X"].value == datetime.date(2019, 1, 1)
|
|
|
|
def test_schedule_block_parses(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
" @2024-01-01 WCONHIST STATUS=OPEN\n"
|
|
"SCHEDULE\n"
|
|
" @2024-01-01 RPTRST BASIC=2 FREQ=1\n"
|
|
" @2024-01-01 GRUPTREE CHILD_GROUP=OP PARENT_GROUP=FIELD\n"
|
|
'WELL "W"\n'
|
|
" @2024-02-01 WELTARG CMODE=ORAT VALUE=5000\n"
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert [e.event_type for e in doc.schedule_events] == ["RPTRST", "GRUPTREE"]
|
|
assert doc.schedule_events[0].attributes["BASIC"].value == 2
|
|
# WELL after SCHEDULE switches the sink back to the well block.
|
|
assert [len(w.events) for w in doc.wells] == [1, 1]
|
|
|
|
def test_group_blocks_parse_and_switch_event_sink(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nGROUP "OP"\n'
|
|
" @2020-07-01 GEFAC FACTOR=1.0 TRANSFER=YES\n"
|
|
" @2020-07-01 GCONPROD CMODE=LRAT LRAT=20000\n"
|
|
'GROUP "WI"\n'
|
|
" @2020-07-01 GCONINJE TYPE=WATER CMODE=RATE RATE=16000\n"
|
|
"SCHEDULE\n"
|
|
" @2020-07-01 RPTRST BASIC=2\n"
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert [group.group_name for group in doc.groups] == ["OP", "WI"]
|
|
assert [event.event_type for event in doc.groups[0].events] == [
|
|
"GEFAC",
|
|
"GCONPROD",
|
|
]
|
|
assert [event.event_type for event in doc.groups[1].events] == ["GCONINJE"]
|
|
assert [event.event_type for event in doc.schedule_events] == ["RPTRST"]
|
|
|
|
def test_empty_group_block_ok(self):
|
|
doc = parse_orion_events('ORIONEVENTS 2.0\nGROUP "OP"\n')
|
|
assert doc.groups[0].group_name == "OP"
|
|
assert doc.groups[0].events == []
|
|
|
|
def test_malformed_group_line_rejected(self):
|
|
with pytest.raises(OrionParseError, match="Malformed GROUP line"):
|
|
parse_orion_events("ORIONEVENTS 2.0\nGROUP OP\n")
|
|
|
|
def test_duplicate_wellspec_for_well_and_date_is_rejected(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
" @2024-01-01 WELLSPEC GROUP=A\n"
|
|
'WELL "W"\n'
|
|
" @2024-01-01 WELLSPEC PHASE=GAS\n"
|
|
)
|
|
with pytest.raises(OrionParseError, match="WELLSPEC already defined.*line 3"):
|
|
parse_orion_events(text)
|
|
|
|
def test_wellspec_same_date_for_different_wells_is_allowed(self):
|
|
document = parse_orion_events(
|
|
'ORIONEVENTS 2.0\nWELL "A"\n'
|
|
" @2024-01-01 WELLSPEC GROUP=GA\n"
|
|
'WELL "B"\n'
|
|
" @2024-01-01 WELLSPEC GROUP=GB\n"
|
|
)
|
|
assert [len(well.events) for well in document.wells] == [1, 1]
|
|
|
|
def test_boolean_attributes_are_typed_unless_quoted(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
"SCHEDULE\n"
|
|
' @2024-01-01 RPTRST DEN=True ROCKC=FALSE LABEL="True"\n'
|
|
)
|
|
attributes = parse_orion_events(text).schedule_events[0].attributes
|
|
|
|
assert attributes["DEN"].value is True
|
|
assert attributes["ROCKC"].value is False
|
|
assert attributes["LABEL"].value == "True"
|
|
assert isinstance(attributes["LABEL"].value, str)
|
|
|
|
def test_single_restart_event_parses(self):
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\nSCHEDULE\n @2024-02-01 RESTART\n"
|
|
)
|
|
restart = document.schedule_events[0]
|
|
assert restart.event_type == "RESTART"
|
|
assert restart.attributes == {}
|
|
|
|
@pytest.mark.parametrize(
|
|
"text,expected_error",
|
|
[
|
|
(
|
|
'ORIONEVENTS 2.0\nWELL "W"\n @2024-01-01 RESTART\n',
|
|
"only valid in a SCHEDULE block",
|
|
),
|
|
(
|
|
'ORIONEVENTS 2.0\nGROUP "G"\n @2024-01-01 RESTART\n',
|
|
"only valid in a SCHEDULE block",
|
|
),
|
|
(
|
|
"ORIONEVENTS 2.0\nSCHEDULE\n @2024-01-01 RESTART VALUE=1\n",
|
|
"takes no attributes",
|
|
),
|
|
(
|
|
"ORIONEVENTS 2.0\nSCHEDULE\n"
|
|
" @2024-01-01 RESTART\n"
|
|
" @2024-02-01 RESTART\n",
|
|
"Only one RESTART event",
|
|
),
|
|
],
|
|
)
|
|
def test_invalid_restart_event_rejected(self, text, expected_error):
|
|
with pytest.raises(OrionParseError, match=expected_error):
|
|
parse_orion_events(text)
|
|
|
|
def test_schedule_line_with_arguments_rejected(self):
|
|
with pytest.raises(OrionParseError, match="SCHEDULE takes no arguments"):
|
|
parse_orion_events("ORIONEVENTS 2.0\nSCHEDULE NOW\n")
|
|
|
|
def test_report_lines_parse(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
"DATE START = 2024-01-01\n"
|
|
"REPORT 2024-06-01\n"
|
|
"REPORT START + 31\n"
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert doc.report_dates == [
|
|
datetime.date(2024, 6, 1),
|
|
datetime.date(2024, 2, 1),
|
|
]
|
|
|
|
def test_report_keeps_duplicates_and_file_order(self):
|
|
text = "ORIONEVENTS 2.0\nREPORT 2024-06-01\nREPORT 2024-06-01\n"
|
|
doc = parse_orion_events(text)
|
|
assert doc.report_dates == [datetime.date(2024, 6, 1)] * 2
|
|
|
|
def test_report_inside_block_does_not_close_it(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
" @2024-01-01 WCONHIST STATUS=OPEN\n"
|
|
"REPORT 2024-06-01\n"
|
|
" @2024-02-01 WELTARG CMODE=ORAT VALUE=5000\n"
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert [len(w.events) for w in doc.wells] == [2]
|
|
assert doc.report_dates == [datetime.date(2024, 6, 1)]
|
|
|
|
def test_report_with_undeclared_variable_raises(self):
|
|
with pytest.raises(OrionParseError, match="NOPE"):
|
|
parse_orion_events("ORIONEVENTS 2.0\nREPORT NOPE + 1\n")
|
|
|
|
def test_malformed_report_line_raises(self):
|
|
with pytest.raises(OrionParseError, match="Malformed REPORT line"):
|
|
parse_orion_events("ORIONEVENTS 2.0\nREPORT\n")
|
|
|
|
def test_report_with_datetime_literal(self):
|
|
text = "ORIONEVENTS 2.0\nREPORT 2024-06-01T14:45:30.500\n"
|
|
doc = parse_orion_events(text)
|
|
assert doc.report_dates == [datetime.datetime(2024, 6, 1, 14, 45, 30, 500000)]
|
|
|
|
def test_datetime_literal_event(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
" @2024-05-15T14:45:30.500 PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].events[0].event_date == datetime.datetime(
|
|
2024, 5, 15, 14, 45, 30, 500000
|
|
)
|
|
|
|
def test_datetime_with_day_offset(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
" @2024-05-15T14:45:30 + 2 PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].events[0].event_date == datetime.datetime(
|
|
2024, 5, 17, 14, 45, 30
|
|
)
|
|
|
|
def test_date_variable_with_time_of_day(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nDATE T0 = 2024-05-15T14:45:30.500\n"
|
|
'WELL "W"\n @T0 + 1 PERFORATION MDSTART=1 MDEND=2\n'
|
|
)
|
|
doc = parse_orion_events(text)
|
|
assert doc.wells[0].events[0].event_date == datetime.datetime(
|
|
2024, 5, 16, 14, 45, 30, 500000
|
|
)
|
|
|
|
|
|
class TestFilterParsing:
|
|
def _perf_with_filter(self, decls, filter_value):
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
+ decls
|
|
+ 'WELL "W"\n'
|
|
+ f" @2018-01-01 PERFORATION MDSTART=1 MDEND=2 FILTER={filter_value}\n"
|
|
)
|
|
return parse_orion_events(text)
|
|
|
|
def test_filter_declaration_single_term(self):
|
|
doc = parse_orion_events('ORIONEVENTS 2.0\nFILTER F = "poro > 0.4"\n')
|
|
value = doc.variables["F"]
|
|
assert value.kind == "FILTER"
|
|
expr = value.value
|
|
assert expr.combine_mode == "AND"
|
|
assert len(expr.terms) == 1
|
|
term = expr.terms[0]
|
|
assert term.result_name == "PORO" # uppercased
|
|
assert term.result_type is None
|
|
assert term.op == ">"
|
|
assert term.value == 0.4
|
|
|
|
def test_filter_declaration_two_terms_and(self):
|
|
doc = parse_orion_events(
|
|
'ORIONEVENTS 2.0\nFILTER F = "poro>0.4 AND permx > 100.0"\n'
|
|
)
|
|
expr = doc.variables["F"].value
|
|
assert expr.combine_mode == "AND"
|
|
assert [t.result_name for t in expr.terms] == ["PORO", "PERMX"]
|
|
|
|
def test_filter_declaration_or_mode(self):
|
|
doc = parse_orion_events(
|
|
'ORIONEVENTS 2.0\nFILTER F = "PORO < 0.1 OR PERMX <= 10"\n'
|
|
)
|
|
expr = doc.variables["F"].value
|
|
assert expr.combine_mode == "OR"
|
|
assert [t.op for t in expr.terms] == ["<", "<="]
|
|
|
|
def test_filter_all_operators(self):
|
|
doc = parse_orion_events(
|
|
'ORIONEVENTS 2.0\nFILTER F = "A > 1 AND B >= 2 AND C < 3 AND D <= 4"\n'
|
|
)
|
|
expr = doc.variables["F"].value
|
|
assert [t.op for t in expr.terms] == [">", ">=", "<", "<="]
|
|
assert [t.value for t in expr.terms] == [1.0, 2.0, 3.0, 4.0]
|
|
|
|
def test_filter_scientific_notation_value(self):
|
|
doc = parse_orion_events('ORIONEVENTS 2.0\nFILTER F = "PERMX < 1.5e4"\n')
|
|
assert doc.variables["F"].value.terms[0].value == 15000.0
|
|
|
|
def test_filter_qualified_result_type(self):
|
|
doc = parse_orion_events(
|
|
'ORIONEVENTS 2.0\nFILTER F = "DYNAMIC_NATIVE.MY_PROPERTY > 1"\n'
|
|
)
|
|
term = doc.variables["F"].value.terms[0]
|
|
assert term.result_type == "DYNAMIC_NATIVE"
|
|
assert term.result_name == "MY_PROPERTY"
|
|
|
|
def test_filter_qualifier_alias_case_insensitive(self):
|
|
for qualifier, expected in (
|
|
("dynamic", "DYNAMIC_NATIVE"),
|
|
("static", "STATIC_NATIVE"),
|
|
("Generated", "GENERATED"),
|
|
("static_native", "STATIC_NATIVE"),
|
|
):
|
|
doc = parse_orion_events(
|
|
f'ORIONEVENTS 2.0\nFILTER F = "{qualifier}.my_property > 1"\n'
|
|
)
|
|
term = doc.variables["F"].value.terms[0]
|
|
assert term.result_type == expected
|
|
assert term.result_name == "MY_PROPERTY"
|
|
|
|
def test_perforation_filter_reference_resolves(self):
|
|
doc = self._perf_with_filter('FILTER poroperm = "poro>0.4"\n', "poroperm")
|
|
event = doc.wells[0].events[0]
|
|
assert event.filter is not None
|
|
assert event.filter.name == "poroperm"
|
|
assert event.filter.expr.terms[0].result_name == "PORO"
|
|
# The attribute dict stays lossless.
|
|
assert event.attributes["FILTER"].value == "poroperm"
|
|
|
|
def test_perforation_inline_filter_expression(self):
|
|
doc = self._perf_with_filter("", '"permx > 100 OR poro < 0.1"')
|
|
event = doc.wells[0].events[0]
|
|
assert event.filter is not None
|
|
assert event.filter.name is None
|
|
assert event.filter.expr.combine_mode == "OR"
|
|
assert [t.result_name for t in event.filter.expr.terms] == ["PERMX", "PORO"]
|
|
|
|
def test_filter_on_non_perforation_event_is_plain_attribute(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "W"\n'
|
|
' @2018-01-01 WCONHIST STATUS=OPEN FILTER="PERMX > 200"\n'
|
|
)
|
|
doc = parse_orion_events(text)
|
|
event = doc.wells[0].events[0]
|
|
assert event.filter is None
|
|
assert event.attributes["FILTER"].value == "PERMX > 200"
|
|
|
|
def test_mixed_and_or_raises(self):
|
|
with pytest.raises(OrionParseError, match="mixes AND and OR"):
|
|
parse_orion_events(
|
|
'ORIONEVENTS 2.0\nFILTER F = "A > 1 AND B > 2 OR C > 3"\n'
|
|
)
|
|
|
|
def test_lowercase_connector_raises(self):
|
|
with pytest.raises(OrionParseError, match="uppercase AND / OR"):
|
|
parse_orion_events('ORIONEVENTS 2.0\nFILTER F = "poro>0.4 and permx>1"\n')
|
|
|
|
def test_equality_operator_raises(self):
|
|
with pytest.raises(OrionParseError, match="only >, >=, < and <="):
|
|
parse_orion_events('ORIONEVENTS 2.0\nFILTER F = "poro = 0.4"\n')
|
|
|
|
def test_function_style_result_name_raises(self):
|
|
with pytest.raises(OrionParseError, match="Malformed filter term"):
|
|
parse_orion_events('ORIONEVENTS 2.0\nFILTER F = "SOIL(0) > 0.8"\n')
|
|
|
|
def test_non_numeric_value_raises(self):
|
|
with pytest.raises(OrionParseError, match="Malformed filter term"):
|
|
parse_orion_events('ORIONEVENTS 2.0\nFILTER F = "poro > high"\n')
|
|
|
|
def test_empty_filter_expression_raises(self):
|
|
with pytest.raises(OrionParseError, match="Empty filter expression"):
|
|
parse_orion_events('ORIONEVENTS 2.0\nFILTER F = ""\n')
|
|
|
|
def test_unknown_qualifier_raises_with_hint(self):
|
|
with pytest.raises(
|
|
OrionParseError, match="Unknown result type 'DYNAMIK'.*did you mean"
|
|
):
|
|
parse_orion_events('ORIONEVENTS 2.0\nFILTER F = "DYNAMIK.PORO > 1"\n')
|
|
|
|
def test_malformed_filter_declaration_raises(self):
|
|
with pytest.raises(OrionParseError, match="Malformed FILTER declaration"):
|
|
parse_orion_events("ORIONEVENTS 2.0\nFILTER F = poro>0.4\n")
|
|
|
|
def test_undeclared_filter_reference_raises_with_hint(self):
|
|
with pytest.raises(OrionParseError, match="did you mean 'poroperm'"):
|
|
self._perf_with_filter('FILTER poroperm = "poro>1"\n', "poropermm")
|
|
|
|
def test_wrong_kind_filter_reference_raises(self):
|
|
with pytest.raises(OrionParseError, match="is a DATE .* but a FILTER"):
|
|
self._perf_with_filter("DATE X = 2018-01-01\n", "X")
|
|
|
|
def test_filter_variable_in_date_context_raises(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nFILTER F = "poro>1"\nWELL "W"\n'
|
|
" @F PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
with pytest.raises(OrionParseError, match="is a FILTER .* but a DATE"):
|
|
parse_orion_events(text)
|
|
|
|
def test_non_identifier_filter_value_raises(self):
|
|
with pytest.raises(OrionParseError, match="must name a declared FILTER"):
|
|
self._perf_with_filter("", "123")
|
|
|
|
def test_duplicate_filter_declaration_warns(self):
|
|
text = 'ORIONEVENTS 2.0\nFILTER F = "poro>1"\nFILTER F = "permx>2"\n'
|
|
doc = parse_orion_events(text)
|
|
assert any("Duplicate FILTER" in w.message for w in doc.warnings)
|
|
assert doc.variables["F"].value.terms[0].result_name == "PERMX"
|
|
|
|
|
|
class TestDiagnostics:
|
|
def test_all_errors_reported_in_one_pass(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
"DATE A1_STARTUP = 2018-01-01\n"
|
|
"SET X = 2018-01-01\n" # line 3
|
|
'WELL "W"\n'
|
|
" @A1_STRTUP PERFORATION MDSTART=1 MDEND=2\n" # line 5
|
|
" @A1_STARTUP + NOPE WCONHIST STATUS=OPEN\n" # line 6
|
|
)
|
|
with pytest.raises(OrionParseError) as excinfo:
|
|
parse_orion_events(text)
|
|
assert [issue.loc.line for issue in excinfo.value.errors] == [3, 5, 6]
|
|
|
|
def test_malformed_well_block_suppresses_cascading_errors(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
"WELL 55_33-A-2\n" # malformed: unquoted special characters
|
|
" @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n"
|
|
" @2018-01-01 WCONHIST STATUS=OPEN\n"
|
|
)
|
|
with pytest.raises(OrionParseError) as excinfo:
|
|
parse_orion_events(text)
|
|
assert len(excinfo.value.errors) == 1
|
|
assert "Malformed WELL line" in excinfo.value.errors[0].message
|
|
|
|
def test_unknown_variable_hint(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nDATE A1_STARTUP = 2018-01-01\n"
|
|
'WELL "W"\n @A1_STRTUP PERFORATION MDSTART=1 MDEND=2\n'
|
|
)
|
|
with pytest.raises(OrionParseError, match="did you mean 'A1_STARTUP'"):
|
|
parse_orion_events(text)
|
|
|
|
def test_misspelled_keyword_hint(self):
|
|
with pytest.raises(OrionParseError, match="did you mean 'DURATION'"):
|
|
parse_orion_events("ORIONEVENTS 2.0\nDURATON X = 5\n")
|
|
|
|
|
|
class TestValidatorCli:
|
|
def _write(self, tmp_path, text):
|
|
path = tmp_path / "events.orion"
|
|
path.write_text(text)
|
|
return str(path)
|
|
|
|
def test_valid_file_exits_zero_with_summary(self, tmp_path, capsys):
|
|
path = self._write(tmp_path, SAMPLE)
|
|
assert _cli([path]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "OK" in out
|
|
assert "4 variable(s), 2 well block(s), 4 well event(s)" in out
|
|
|
|
def test_invalid_file_exits_nonzero_with_errors(self, tmp_path, capsys):
|
|
path = self._write(
|
|
tmp_path,
|
|
'ORIONEVENTS 2.0\nSET X = 2018-01-01\nWELL "W"\n @NOPE WCONHIST A=1\n',
|
|
)
|
|
assert _cli([path]) == 1
|
|
out = capsys.readouterr().out
|
|
assert "Line 2" in out
|
|
assert "Line 4" in out
|
|
assert "2 error(s) found" in out
|
|
|
|
def test_missing_file_exits_nonzero(self, tmp_path, capsys):
|
|
assert _cli([str(tmp_path / "nope.orion")]) == 1
|
|
assert "Error" in capsys.readouterr().out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layer B: applying
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApplying:
|
|
def _apply(self, text, names=("55_33-A-1", "55_33-A-2"), **opts):
|
|
doc = parse_orion_events(text)
|
|
timeline = FakeTimeline()
|
|
project = FakeProject(names)
|
|
report = apply_orion_document(doc, timeline, project, **opts)
|
|
return timeline, report
|
|
|
|
def test_same_owner_type_and_date_events_are_merged(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
'WELL "55_33-A-1"\n'
|
|
" @2018-01-01 WCONHIST STATUS=OPEN ORAT=100\n"
|
|
'WELL "55_33-A-1"\n'
|
|
" @2018-01-01 wconhist STATUS=SHUT CMODE=ORAT\n"
|
|
" @2018-01-02 WCONHIST STATUS=OPEN\n"
|
|
'WELL "55_33-A-2"\n'
|
|
" @2018-01-01 WCONHIST STATUS=OPEN\n"
|
|
)
|
|
document = parse_orion_events(text)
|
|
merged = coalesce_orion_document(document)
|
|
|
|
# Normalization does not mutate the parsed source representation.
|
|
assert len(document.wells) == 3
|
|
assert len(merged.wells) == 2
|
|
assert len(merged.wells[0].events) == 2
|
|
|
|
merged_event = merged.wells[0].events[0]
|
|
assert merged_event.event_type == "WCONHIST"
|
|
assert merged_event.attributes["STATUS"].value == "SHUT"
|
|
assert merged_event.attributes["ORAT"].value == 100
|
|
assert merged_event.attributes["CMODE"].value == "ORAT"
|
|
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 3
|
|
assert len(timeline.keyword_calls) == 3
|
|
assert timeline.keyword_calls[0]["keyword_data"]["STATUS"] == "SHUT"
|
|
assert timeline.keyword_calls[0]["keyword_data"]["ORAT"] == 100
|
|
assert timeline.keyword_calls[0]["keyword_data"]["CMODE"] == "ORAT"
|
|
|
|
def test_group_and_schedule_events_merge_only_within_owner(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
'GROUP "OP"\n'
|
|
" @2018-01-01 GCONPROD CONTROL_MODE=ORAT\n"
|
|
'GROUP "OP"\n'
|
|
" @2018-01-01 GCONPROD OIL_TARGET=100\n"
|
|
'GROUP "OTHER"\n'
|
|
" @2018-01-01 GCONPROD OIL_TARGET=200\n"
|
|
"SCHEDULE\n"
|
|
" @2018-01-01 RPTRST BASIC=1\n"
|
|
"SCHEDULE\n"
|
|
" @2018-01-01 RPTRST FREQ=2\n"
|
|
)
|
|
merged = coalesce_orion_document(parse_orion_events(text))
|
|
|
|
assert len(merged.groups) == 2
|
|
assert len(merged.groups[0].events) == 1
|
|
assert set(merged.groups[0].events[0].attributes) == {
|
|
"CONTROL_MODE",
|
|
"OIL_TARGET",
|
|
}
|
|
assert len(merged.groups[1].events) == 1
|
|
assert len(merged.schedule_events) == 1
|
|
assert set(merged.schedule_events[0].attributes) == {"BASIC", "FREQ"}
|
|
|
|
def test_perforation_mapping_radius_to_diameter(self):
|
|
timeline, report = self._apply(SAMPLE)
|
|
assert report.events_applied == 4 # 2 perfs + WCONHIST + WELTARG
|
|
first = timeline.perf_calls[0]
|
|
assert first["start_md"] == 1644.49
|
|
assert first["end_md"] == 1664.28
|
|
assert first["diameter"] == pytest.approx(2 * 0.12065)
|
|
assert first["skin_factor"] == 5.0
|
|
assert first["completion_number"] == 1
|
|
assert first["state"] == "OPEN"
|
|
assert first["event_date"] == "2018-01-01"
|
|
|
|
def test_wconhist_field_translation_and_well_injection(self):
|
|
timeline, _ = self._apply(SAMPLE)
|
|
wconhist = next(
|
|
c for c in timeline.keyword_calls if c["keyword_name"] == "WCONHIST"
|
|
)
|
|
data = wconhist["keyword_data"]
|
|
assert data["WELL"] == "55_33-A-1"
|
|
assert data["STATUS"] == "OPEN"
|
|
assert data["CMODE"] == "ORAT"
|
|
assert data["VFP_TABLE"] == 1 # VFP -> VFP_TABLE
|
|
assert "VFP" not in data
|
|
|
|
def test_comment_is_applied_to_timeline_event_not_keyword_data(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
' @2018-01-01 WCONHIST STATUS=OPEN COMMENT="Startup target"\n'
|
|
)
|
|
timeline, report = self._apply(text)
|
|
|
|
assert report.errors == []
|
|
assert "COMMENT" not in timeline.keyword_calls[0]["keyword_data"]
|
|
assert timeline.created_events[0].comment == "Startup target"
|
|
assert timeline.created_events[0].update_calls == 1
|
|
|
|
def test_perforation_comment_is_applied(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2018-01-01 PERFORATION MDSTART=1 MDEND=2 COMMENT=Interval\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
|
|
assert report.errors == []
|
|
assert timeline.perf_events[0].comment == "Interval"
|
|
assert timeline.perf_events[0].update_calls == 1
|
|
|
|
def test_weltarg_value_translation(self):
|
|
timeline, _ = self._apply(SAMPLE)
|
|
weltarg = next(
|
|
c for c in timeline.keyword_calls if c["keyword_name"] == "WELTARG"
|
|
)
|
|
data = weltarg["keyword_data"]
|
|
assert data["NEW_VALUE"] == 50 # VALUE -> NEW_VALUE
|
|
assert data["CMODE"] == "BHP"
|
|
|
|
def test_keyword_attributes_forward_without_special_casing(self):
|
|
# DSHIFT is not part of the format; it forwards unchanged like any
|
|
# other attribute instead of being stripped.
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2018-01-01 WCONHIST STATUS=OPEN CMODE=ORAT DSHIFT=10\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
data = timeline.keyword_calls[0]["keyword_data"]
|
|
assert data["DSHIFT"] == 10
|
|
assert timeline.keyword_calls[0]["event_date"] == "2018-01-01"
|
|
assert not report.warnings
|
|
|
|
def test_report_dates_on_apply_report(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2018-01-01 WCONHIST STATUS=OPEN\n"
|
|
"REPORT 2018-07-01\n"
|
|
"REPORT 2018-03-01\n"
|
|
"REPORT 2018-07-01\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
# Sorted, deduplicated ISO strings ready for
|
|
# generate_schedule_text(additional_dates=...). No timeline events.
|
|
assert report.report_dates == ["2018-03-01", "2018-07-01"]
|
|
assert report.events_applied == 1
|
|
|
|
def test_perfid_on_perforation_is_unknown_attribute_error(self):
|
|
# PERFID is not part of the format; it is rejected like any other
|
|
# unknown completion attribute.
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2018-01-01 PERFORATION MDSTART=1 MDEND=2 PERFID=Valysar\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 0
|
|
assert report.events_skipped == 1
|
|
assert any("PERFID" in e for e in report.errors)
|
|
assert not timeline.perf_calls
|
|
|
|
def test_filter_on_keyword_event_warns_and_applies(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
' @2018-01-01 WCONHIST STATUS=OPEN FILTER="PERMX > 200"\n'
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 1
|
|
assert "FILTER" not in timeline.keyword_calls[0]["keyword_data"]
|
|
assert any("FILTER" in w for w in report.warnings)
|
|
|
|
def test_unknown_well_warns_and_skips(self):
|
|
timeline, report = self._apply(SAMPLE, names=("55_33-A-1",))
|
|
# Second well is unknown -> its single perforation is skipped.
|
|
assert report.events_skipped == 1
|
|
assert any("55_33-A-2" in w for w in report.warnings)
|
|
|
|
def test_unknown_well_error_policy_raises(self):
|
|
from rips.exception import RipsError
|
|
|
|
with pytest.raises(RipsError):
|
|
self._apply(SAMPLE, names=("55_33-A-1",), on_unknown_well="error")
|
|
|
|
def test_unknown_event_type_warns_with_hint(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 WCONHST STATUS=OPEN\n'
|
|
_, report = self._apply(text)
|
|
assert report.events_skipped == 1
|
|
assert any(
|
|
"WCONHST" in w and "did you mean 'WCONHIST'" in w for w in report.warnings
|
|
)
|
|
|
|
def test_perforation_missing_required_attr_is_error(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 PERFORATION MDSTART=1\n'
|
|
)
|
|
_, report = self._apply(text)
|
|
assert report.events_skipped == 1
|
|
assert any("MDEND" in e for e in report.errors)
|
|
|
|
def test_perforation_unknown_attr_is_error(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2 ZZZ=3\n'
|
|
_, report = self._apply(text)
|
|
assert report.events_skipped == 1
|
|
assert any("ZZZ" in e for e in report.errors)
|
|
|
|
def test_wellspec_partial_updates_are_cumulative_by_date(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2019-01-01 WELLSPEC CROSSFLOW=False PHASE=gas\n"
|
|
" @2018-01-01 WELLSPEC GROUP=my_group REFDEPTH=1002 PHASE=water\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
|
|
assert report.errors == []
|
|
assert report.events_applied == 2
|
|
# Calls retain source order, but snapshots are resolved chronologically.
|
|
assert timeline.wellspec_calls[0] == {
|
|
"event_date": "2019-01-01",
|
|
"well_path": timeline.wellspec_calls[0]["well_path"],
|
|
"group_name": "my_group",
|
|
"allow_cross_flow": False,
|
|
"reference_depth": 1002.0,
|
|
"well_type": "GAS",
|
|
}
|
|
assert timeline.wellspec_calls[1]["group_name"] == "my_group"
|
|
assert timeline.wellspec_calls[1]["allow_cross_flow"] is True
|
|
assert timeline.wellspec_calls[1]["well_type"] == "WATER"
|
|
|
|
@pytest.mark.parametrize(
|
|
"attributes,expected_error",
|
|
[
|
|
("CROSSFLOW=YES", "CROSSFLOW must be True or False"),
|
|
("REFDEPTH=deep", "REFDEPTH must be numeric"),
|
|
("PHASE=steam", "PHASE must be OIL, GAS, WATER, or LIQUID"),
|
|
("GROUP=1", "GROUP must be a non-empty string"),
|
|
("UNKNOWN=1", "unknown WELLSPEC attribute"),
|
|
("COMMENT=empty", "needs at least one setting attribute"),
|
|
],
|
|
)
|
|
def test_invalid_wellspec_is_reported_and_skipped(self, attributes, expected_error):
|
|
text = (
|
|
f'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 WELLSPEC {attributes}\n'
|
|
)
|
|
timeline, report = self._apply(text)
|
|
|
|
assert report.events_applied == 0
|
|
assert report.events_skipped == 1
|
|
assert expected_error in report.errors[0]
|
|
assert timeline.wellspec_calls == []
|
|
|
|
def test_tubing_mapping(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2024-01-01 TUBING MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 ROUGHNESS=1.0e-5\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 1
|
|
call = timeline.tubing_calls[0]
|
|
assert call["start_md"] == 0.0
|
|
assert call["end_md"] == 2500.0
|
|
assert call["inner_diameter"] == 0.15
|
|
assert call["roughness"] == pytest.approx(1.0e-5)
|
|
|
|
def test_valve_mapping(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2024-03-01 VALVE MD=2100 TYPE=ICV STATE=OPEN CV=0.7 AREA=0.0001\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 1
|
|
call = timeline.valve_calls[0]
|
|
assert call["measured_depth"] == 2100.0
|
|
assert call["valve_type"] == "ICV"
|
|
assert call["state"] == "OPEN"
|
|
assert call["flow_coefficient"] == 0.7
|
|
assert call["area"] == pytest.approx(0.0001)
|
|
|
|
def test_valve_missing_type_is_error(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2024-03-01 VALVE MD=2100\n'
|
|
_, report = self._apply(text)
|
|
assert report.events_skipped == 1
|
|
assert any("TYPE" in e for e in report.errors)
|
|
|
|
def test_state_mapping(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2024-02-15 STATE STATE=SHUT\n'
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 1
|
|
assert timeline.state_calls[0]["well_state"] == "SHUT"
|
|
|
|
def test_generic_well_keyword_pass_through(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2024-06-01 WRFTPLT OUTPUT_RFT=YES OUTPUT_PLT=NO OUTPUT_SEGMENT=NO\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 1
|
|
assert report.warnings == []
|
|
call = timeline.keyword_calls[0]
|
|
assert call["keyword_name"] == "WRFTPLT"
|
|
assert call["keyword_data"]["WELL"] == "55_33-A-1"
|
|
assert call["keyword_data"]["OUTPUT_RFT"] == "YES"
|
|
|
|
def test_typo_of_builtin_is_not_passed_through(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2024-01-01 PERFORATIN MDSTART=1 MDEND=2\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_skipped == 1
|
|
assert timeline.keyword_calls == []
|
|
assert any("did you mean 'PERFORATION'" in w for w in report.warnings)
|
|
|
|
def test_schedule_events_applied_without_well(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nSCHEDULE\n"
|
|
" @2024-01-01 RPTRST BASIC=2 FREQ=1\n"
|
|
" @2024-01-01 TUNING TSINIT=1 TSMAXZ=30\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 2
|
|
rptrst = timeline.schedule_keyword_calls[0]
|
|
assert rptrst["keyword_name"] == "RPTRST"
|
|
assert rptrst["keyword_data"] == {"BASIC": 2, "FREQ": 1}
|
|
assert "WELL" not in rptrst["keyword_data"]
|
|
|
|
def test_restart_event_creates_non_emitting_timeline_marker(self):
|
|
text = "ORIONEVENTS 2.0\nSCHEDULE\n @2024-02-01 RESTART\n"
|
|
timeline, report = self._apply(text)
|
|
|
|
assert report.events_applied == 1
|
|
assert report.errors == []
|
|
assert timeline.schedule_keyword_calls == [
|
|
{
|
|
"event_date": "2024-02-01",
|
|
"keyword_name": "RESTART",
|
|
"keyword_data": {},
|
|
}
|
|
]
|
|
|
|
def test_group_events_inject_group_name(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nGROUP "OP"\n'
|
|
" @2020-07-01 GEFAC EFFICIENCY_FACTOR=1.0 USE_GEFAC_IN_NETWORK=YES\n"
|
|
" @2020-07-01 GCONPROD CONTROL_MODE=LRAT LIQUID_TARGET=20000 WATER_TARGET=20000 OIL_TARGET=20000\n"
|
|
'GROUP "WI"\n'
|
|
" @2020-07-01 GCONINJE PHASE=WATER CONTROL_MODE=RATE SURFACE_TARGET=16000\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_applied == 3
|
|
assert [call["keyword_name"] for call in timeline.schedule_keyword_calls] == [
|
|
"GEFAC",
|
|
"GCONPROD",
|
|
"GCONINJE",
|
|
]
|
|
assert timeline.schedule_keyword_calls[0]["keyword_data"] == {
|
|
"GROUP": "OP",
|
|
"EFFICIENCY_FACTOR": 1.0,
|
|
"USE_GEFAC_IN_NETWORK": "YES",
|
|
}
|
|
assert timeline.schedule_keyword_calls[1]["keyword_data"]["GROUP"] == "OP"
|
|
assert timeline.schedule_keyword_calls[2]["keyword_data"]["GROUP"] == "WI"
|
|
|
|
def test_member_event_expands_to_unique_grouptree_events(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nGROUP "PRODUCERS"\n'
|
|
' @2024-01-01 MEMBER MEMBERS="WELL_A, WELL_B,WELL_A" '
|
|
'COMMENT="Group membership"\n'
|
|
)
|
|
timeline, report = self._apply(text)
|
|
|
|
assert report.events_applied == 2
|
|
assert report.errors == []
|
|
assert [call["keyword_data"] for call in timeline.schedule_keyword_calls] == [
|
|
{"CHILD_GROUP": "WELL_A", "PARENT_GROUP": "PRODUCERS"},
|
|
{"CHILD_GROUP": "WELL_B", "PARENT_GROUP": "PRODUCERS"},
|
|
]
|
|
assert all(
|
|
event.comment == "Group membership" for event in timeline.created_events
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"block,event,expected_error",
|
|
[
|
|
("SCHEDULE", 'MEMBER MEMBERS="A"', "needs a GROUP block"),
|
|
('GROUP "G"', "MEMBER", "missing required attribute"),
|
|
(
|
|
'GROUP "G"',
|
|
'MEMBER MEMBERS="A,,B"',
|
|
"non-empty names",
|
|
),
|
|
(
|
|
'GROUP "G"',
|
|
'MEMBER MEMBERS="A" EXTRA=1',
|
|
"unknown MEMBER attribute",
|
|
),
|
|
],
|
|
)
|
|
def test_invalid_member_event_is_skipped(self, block, event, expected_error):
|
|
text = f"ORIONEVENTS 2.0\n{block}\n @2024-01-01 {event}\n"
|
|
timeline, report = self._apply(text)
|
|
|
|
assert report.events_applied == 0
|
|
assert report.events_skipped == 1
|
|
assert timeline.schedule_keyword_calls == []
|
|
assert expected_error in report.errors[0]
|
|
|
|
def test_completion_event_in_schedule_block_is_error(self):
|
|
text = (
|
|
"ORIONEVENTS 2.0\nSCHEDULE\n @2024-01-01 PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
timeline, report = self._apply(text)
|
|
assert report.events_skipped == 1
|
|
assert timeline.schedule_keyword_calls == []
|
|
assert any("needs a WELL block" in e for e in report.errors)
|
|
|
|
def test_datetime_event_date_keeps_milliseconds(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
|
|
" @2024-05-15T14:45:30.500 PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
timeline, _ = self._apply(text)
|
|
assert timeline.perf_calls[0]["event_date"] == "2024-05-15T14:45:30.500"
|
|
|
|
def test_invalid_policy_value_raises(self):
|
|
doc = parse_orion_events(SAMPLE)
|
|
with pytest.raises(ValueError):
|
|
apply_orion_document(
|
|
doc, FakeTimeline(), FakeProject(["x"]), on_unknown_well="bogus"
|
|
)
|
|
|
|
def test_apply_report_default_is_empty(self):
|
|
report = ApplyReport()
|
|
assert report.events_applied == 0
|
|
assert report.warnings == []
|
|
|
|
|
|
class TestFilterApplying:
|
|
def _apply(self, text, case=None, cases=None, **opts):
|
|
doc = parse_orion_events(text)
|
|
timeline = FakeTimeline()
|
|
project = FakeProject(
|
|
["55_33-A-1"], cases=cases if cases is not None else [FakeCase()]
|
|
)
|
|
report = apply_orion_document(doc, timeline, project, case=case, **opts)
|
|
return timeline, project, report
|
|
|
|
def _perf_text(self, decls, *filter_values):
|
|
events = "".join(
|
|
f" @2018-01-{index:02d} PERFORATION MDSTART=1 MDEND=2 FILTER={value}\n"
|
|
for index, value in enumerate(filter_values, start=1)
|
|
)
|
|
return "ORIONEVENTS 2.0\n" + decls + 'WELL "55_33-A-1"\n' + events
|
|
|
|
def test_declared_filter_creates_combined_filter_and_attaches(self):
|
|
text = self._perf_text(
|
|
'FILTER poroperm = "poro>0.4 AND permx > 100.0"\n', "poroperm"
|
|
)
|
|
timeline, project, report = self._apply(text)
|
|
assert report.events_applied == 1
|
|
assert report.errors == []
|
|
assert report.warnings == [] # FILTER no longer warns on PERFORATION
|
|
|
|
combined_filters = project.cases()[0].data_filter_collection().combined_filters
|
|
assert len(combined_filters) == 1
|
|
combined = combined_filters[0]
|
|
assert combined.name == "poroperm"
|
|
assert combined.combine_mode == "AND"
|
|
|
|
poro, permx = combined.property_filters
|
|
assert (poro.result_variable, poro.result_type) == ("PORO", "STATIC_NATIVE")
|
|
assert (permx.result_variable, permx.result_type) == ("PERMX", "STATIC_NATIVE")
|
|
# '>' sets only the lower bound; the default upper bound is untouched.
|
|
assert poro.lower_bound == 0.4
|
|
assert poro.upper_bound == 1.0e20
|
|
assert permx.lower_bound == 100.0
|
|
assert poro.update_calls == 1
|
|
|
|
assert timeline.perf_events[0].filters == [combined]
|
|
|
|
def test_upper_bound_operators_and_or_mode(self):
|
|
text = self._perf_text("", '"PORO < 0.1 OR PERMX <= 10"')
|
|
_, project, report = self._apply(text)
|
|
assert report.errors == []
|
|
combined = project.cases()[0].data_filter_collection().combined_filters[0]
|
|
assert combined.combine_mode == "OR"
|
|
assert combined.name == "" # anonymous inline filter: auto-derived name
|
|
poro, permx = combined.property_filters
|
|
assert poro.upper_bound == 0.1
|
|
assert poro.lower_bound == -1.0e20
|
|
assert permx.upper_bound == 10.0
|
|
|
|
def test_declared_filter_shared_between_perforations(self):
|
|
text = self._perf_text('FILTER poroperm = "poro>0.4"\n', "poroperm", "poroperm")
|
|
timeline, project, _ = self._apply(text)
|
|
combined_filters = project.cases()[0].data_filter_collection().combined_filters
|
|
assert len(combined_filters) == 1
|
|
assert timeline.perf_events[0].filters == timeline.perf_events[1].filters
|
|
|
|
def test_identical_inline_filters_shared_distinct_not(self):
|
|
text = self._perf_text("", '"poro>0.4"', '"poro>0.4"', '"poro>0.5"')
|
|
timeline, project, _ = self._apply(text)
|
|
combined_filters = project.cases()[0].data_filter_collection().combined_filters
|
|
assert len(combined_filters) == 2
|
|
assert timeline.perf_events[0].filters == timeline.perf_events[1].filters
|
|
assert timeline.perf_events[2].filters != timeline.perf_events[0].filters
|
|
|
|
def test_unqualified_name_prefers_static_over_dynamic(self):
|
|
# PORO exists in both STATIC_NATIVE and DYNAMIC_NATIVE in FakeCase.
|
|
text = self._perf_text("", '"PORO > 0.2"')
|
|
_, project, _ = self._apply(text)
|
|
combined = project.cases()[0].data_filter_collection().combined_filters[0]
|
|
assert combined.property_filters[0].result_type == "STATIC_NATIVE"
|
|
|
|
def test_qualified_name_searches_only_that_type(self):
|
|
text = self._perf_text("", '"dynamic.PORO > 0.2"')
|
|
_, project, _ = self._apply(text)
|
|
combined = project.cases()[0].data_filter_collection().combined_filters[0]
|
|
assert combined.property_filters[0].result_type == "DYNAMIC_NATIVE"
|
|
|
|
def test_explicit_case_overrides_project_first_case(self):
|
|
explicit_case = FakeCase()
|
|
text = self._perf_text("", '"PORO > 0.2"')
|
|
_, project, _ = self._apply(text, case=explicit_case)
|
|
assert explicit_case.data_filter_collection().combined_filters
|
|
first_case = project.cases()[0]
|
|
assert not first_case.data_filter_collection().combined_filters
|
|
|
|
def test_filter_without_case_raises(self):
|
|
from rips.exception import RipsError
|
|
|
|
text = self._perf_text("", '"PORO > 0.2"')
|
|
with pytest.raises(RipsError, match="no case is available"):
|
|
self._apply(text, cases=[])
|
|
|
|
def test_no_filter_without_case_is_fine(self):
|
|
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n'
|
|
_, _, report = self._apply(text, cases=[])
|
|
assert report.events_applied == 1
|
|
|
|
def test_missing_result_raises_before_applying(self):
|
|
from rips.exception import RipsError
|
|
|
|
text = self._perf_text("", '"NOTHERE > 1"')
|
|
doc = parse_orion_events(text)
|
|
timeline = FakeTimeline()
|
|
project = FakeProject(["55_33-A-1"], cases=[FakeCase()])
|
|
with pytest.raises(
|
|
RipsError,
|
|
match="'NOTHERE' not found .searched STATIC_NATIVE, DYNAMIC_NATIVE, GENERATED",
|
|
):
|
|
apply_orion_document(doc, timeline, project)
|
|
assert timeline.perf_calls == [] # nothing applied
|
|
|
|
def test_missing_qualified_result_raises(self):
|
|
from rips.exception import RipsError
|
|
|
|
text = self._perf_text("", '"generated.PORO > 1"')
|
|
with pytest.raises(RipsError, match="'PORO' not found among GENERATED results"):
|
|
self._apply(text)
|
|
|
|
def test_declared_but_unused_filter_creates_nothing(self):
|
|
text = (
|
|
'ORIONEVENTS 2.0\nFILTER unused = "poro>0.4"\nWELL "55_33-A-1"\n'
|
|
" @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
_, project, report = self._apply(text)
|
|
assert report.events_applied == 1
|
|
assert not project.cases()[0].data_filter_collection().combined_filters
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: drives the real WellEventTimeline API (needs a ResInsight
|
|
# instance provided by conftest.py).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOrionEventsIntegration:
|
|
@pytest.fixture
|
|
def project_with_case_and_wells(self, rips_instance, initialize_test):
|
|
"""Load the TEST10K case and import two well paths from .dev files."""
|
|
case_root = dataroot.PATH + "/TEST10K_FLT_LGR_NNC"
|
|
project = rips_instance.project
|
|
case = project.load_case(path=case_root + "/TEST10K_FLT_LGR_NNC.EGRID")
|
|
project.import_well_paths(
|
|
well_path_files=[
|
|
case_root + "/wellpath_a.dev",
|
|
case_root + "/wellpath_b.dev",
|
|
]
|
|
)
|
|
well_path_coll = project.descendants(rips.WellPathCollection)[0]
|
|
return project, case, well_path_coll.event_timeline()
|
|
|
|
def test_compdat_invalid_item_names_report_valid_names(
|
|
self, project_with_case_and_wells
|
|
):
|
|
"""Invalid COMPDAT items produce an actionable error (issue #14535)."""
|
|
project, _case, timeline = project_with_case_and_wells
|
|
well = project.well_paths()[0]
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @2024-01-01 COMPDAT STATUS=OPEN TRANSMISSIBILITY=1.0\n"
|
|
)
|
|
|
|
with pytest.raises(rips.RipsError) as exc_info:
|
|
apply_orion_document(document, timeline, project)
|
|
|
|
error_msg = str(exc_info.value)
|
|
assert "Keyword 'COMPDAT' contains invalid item names" in error_msg
|
|
assert "STATUS, TRANSMISSIBILITY" in error_msg
|
|
assert (
|
|
"Valid item names are: WELL, I, J, K1, K2, STATE, SAT_TABLE, "
|
|
"CONNECTION_TRANSMISSIBILITY_FACTOR, DIAMETER, Kh, SKIN, D_FACTOR, "
|
|
"DIR, PR"
|
|
) in error_msg
|
|
|
|
def test_rptrst_boolean_values_emit_bare_mnemonics(
|
|
self, project_with_case_and_wells
|
|
):
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = project.well_paths()[0]
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @2024-01-01 WCONHIST STATUS=OPEN\n"
|
|
"SCHEDULE\n"
|
|
" @2024-01-01 RPTRST BASIC=2 DEN=True ROCKC=True RPORV=True "
|
|
"RFIP=True FLOWS=True FLORES=True NORST=False\n"
|
|
)
|
|
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.events_applied == 2
|
|
|
|
schedule = timeline.generate_schedule_text(
|
|
eclipse_case=case, export_msw_for_wells=[]
|
|
)
|
|
rptrst_block = schedule.split("RPTRST", 1)[1].split("/", 1)[0]
|
|
tokens = rptrst_block.split()
|
|
|
|
assert "BASIC=2" in tokens
|
|
for flag in ("DEN", "ROCKC", "RPORV", "RFIP", "FLOWS", "FLORES"):
|
|
assert flag in tokens
|
|
assert f"{flag}=True" not in rptrst_block
|
|
assert "NORST" not in tokens
|
|
|
|
def test_event_comment_precedes_generated_keyword(
|
|
self, project_with_case_and_wells
|
|
):
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = project.well_paths()[0]
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
' @2024-01-01 WCONHIST STATUS=OPEN COMMENT="Startup target"\n'
|
|
)
|
|
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.errors == []
|
|
|
|
schedule = timeline.generate_schedule_text(eclipse_case=case)
|
|
assert "-- Startup target\nWCONHIST\n" in schedule
|
|
assert "COMMENT" not in schedule
|
|
|
|
def test_group_sections_generate_group_keywords(self, project_with_case_and_wells):
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = project.well_paths()[0]
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @2020-07-01 WCONHIST STATUS=OPEN CMODE=ORAT\n"
|
|
'GROUP "OP"\n'
|
|
" @2020-07-01 GEFAC EFFICIENCY_FACTOR=1.0 USE_GEFAC_IN_NETWORK=YES\n"
|
|
" @2020-07-01 GCONPROD CONTROL_MODE=LRAT LIQUID_TARGET=20000 "
|
|
"WATER_TARGET=20000 OIL_TARGET=20000\n"
|
|
'GROUP "WI"\n'
|
|
" @2020-07-01 GCONINJE PHASE=WATER CONTROL_MODE=RATE "
|
|
"SURFACE_TARGET=16000\n"
|
|
)
|
|
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.errors == []
|
|
assert report.events_applied == 4
|
|
|
|
schedule = timeline.generate_schedule_text(
|
|
eclipse_case=case, export_msw_for_wells=[]
|
|
)
|
|
assert "GEFAC" in schedule
|
|
assert "GCONPROD" in schedule
|
|
assert "GCONINJE" in schedule
|
|
assert "'OP'" in schedule
|
|
assert "'WI'" in schedule
|
|
|
|
def test_member_event_generates_grouptree(self, project_with_case_and_wells):
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = project.well_paths()[0]
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @2024-01-01 WCONHIST STATUS=OPEN\n"
|
|
'GROUP "PRODUCERS"\n'
|
|
' @2024-01-01 MEMBER MEMBERS="WELL_A,WELL_B"\n'
|
|
)
|
|
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.errors == []
|
|
assert report.events_applied == 3
|
|
|
|
schedule = timeline.generate_schedule_text(eclipse_case=case)
|
|
grouptree_block = schedule.split("GRUPTREE", 1)[1].split("\n/\n", 1)[0]
|
|
normalized_block = " ".join(grouptree_block.split())
|
|
assert "'WELL_A' 'PRODUCERS'" in normalized_block
|
|
assert "'WELL_B' 'PRODUCERS'" in normalized_block
|
|
|
|
def test_wellspec_updates_settings_and_generates_cumulative_welspecs(
|
|
self, project_with_case_and_wells
|
|
):
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = next(wp for wp in project.well_paths() if "A" in wp.name)
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @2018-01-01 WELLSPEC GROUP=my_group REFDEPTH=1002 PHASE=water\n"
|
|
" @2019-01-01 WELLSPEC CROSSFLOW=False REFDEPTH=1000 PHASE=oil\n"
|
|
)
|
|
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.errors == []
|
|
assert report.events_applied == 2
|
|
|
|
schedule = timeline.generate_schedule_text(
|
|
eclipse_case=case, first_date_as_comment=False, align_columns=True
|
|
)
|
|
assert schedule.count("WELSPECS\n") == 2
|
|
blocks = schedule.split("WELSPECS\n")[1:]
|
|
first_record = " ".join(blocks[0].split("\n/\n", 1)[0].split())
|
|
second_record = " ".join(blocks[1].split("\n/\n", 1)[0].split())
|
|
|
|
assert "'my_group'" in first_record
|
|
assert "1002" in first_record
|
|
assert "'WATER'" in first_record
|
|
assert "'YES'" in first_record
|
|
assert "1*" not in first_record.split("'my_group'", 1)[1].split("1002", 1)[0]
|
|
|
|
assert "'my_group'" in second_record
|
|
assert "1000" in second_record
|
|
assert "'OIL'" in second_record
|
|
assert "'NO'" in second_record
|
|
|
|
timeline.set_timestamp(timestamp="2018-06-01")
|
|
settings = well.completion_settings()
|
|
assert settings.group_name_for_export == "my_group"
|
|
assert settings.allow_well_cross_flow is True
|
|
assert settings.reference_depth_for_export == 1002
|
|
assert settings.well_type_for_export == "WATER"
|
|
|
|
timeline.set_timestamp(timestamp="2019-06-01")
|
|
settings = well.completion_settings()
|
|
assert settings.group_name_for_export == "my_group"
|
|
assert settings.allow_well_cross_flow is False
|
|
assert settings.reference_depth_for_export == 1000
|
|
assert settings.well_type_for_export == "OIL"
|
|
|
|
def test_restart_truncates_generated_schedule(self, project_with_case_and_wells):
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = project.well_paths()[0]
|
|
document = parse_orion_events(
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @2024-01-01 WCONHIST STATUS=OPEN CMODE=ORAT ORAT=100\n"
|
|
" @2024-02-01 WCONHIST STATUS=OPEN CMODE=ORAT ORAT=200\n"
|
|
" @2024-03-01 WCONHIST STATUS=OPEN CMODE=ORAT ORAT=300\n"
|
|
"REPORT 2024-01-15\n"
|
|
"REPORT 2024-04-01\n"
|
|
"SCHEDULE\n"
|
|
" @2024-02-01 RESTART\n"
|
|
)
|
|
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.errors == []
|
|
|
|
schedule = timeline.generate_schedule_text(
|
|
eclipse_case=case,
|
|
first_date_as_comment=False,
|
|
additional_dates=report.report_dates,
|
|
)
|
|
assert "1 'JAN' 2024" not in schedule
|
|
assert "15 'JAN' 2024" not in schedule
|
|
assert "1 'FEB' 2024" in schedule
|
|
assert "1 'MAR' 2024" in schedule
|
|
assert "1 'APR' 2024" in schedule
|
|
assert " 100" not in schedule
|
|
assert " 200" in schedule
|
|
assert " 300" in schedule
|
|
assert "RESTART\n" not in schedule
|
|
|
|
def test_apply_creates_perforations_and_schedule(self, project_with_case_and_wells):
|
|
"""End-to-end: parse -> apply -> set_timestamp -> generate schedule."""
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = next(wp for wp in project.well_paths() if "A" in wp.name)
|
|
|
|
# Reference the real well name; MD range is valid for well path A.
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
"UNIT METRIC\n"
|
|
"DATE START = 2024-01-01\n"
|
|
"DURATION RAMP = 5 DAYS\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @START PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1\n"
|
|
" @START + RAMP WCONHIST STATUS=OPEN CMODE=ORAT VFP=1\n"
|
|
" @START + RAMP WELTARG CMODE=BHP VALUE=50\n"
|
|
"REPORT 2024-07-01\n"
|
|
)
|
|
document = parse_orion_events(text)
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.errors == []
|
|
assert report.events_applied == 3
|
|
assert report.report_dates == ["2024-07-01"]
|
|
|
|
# Materialize completions from the perforation event.
|
|
timeline.set_timestamp(timestamp="2024-01-15")
|
|
perforations = well.completions().perforations().perforations()
|
|
assert len(perforations) > 0, "Perforation should be created from event"
|
|
perf = perforations[0]
|
|
# RADIUS=0.05 must have been mapped to diameter = 0.1.
|
|
assert abs(perf.start_measured_depth - 2000.0) < 1.0
|
|
assert abs(perf.end_measured_depth - 2200.0) < 1.0
|
|
|
|
# The generated schedule should carry the mapped keywords, and the
|
|
# REPORT date should appear as a bare DATES entry (issue #14514).
|
|
schedule = timeline.generate_schedule_text(
|
|
eclipse_case=case,
|
|
export_msw_for_wells=[],
|
|
additional_dates=report.report_dates,
|
|
)
|
|
assert "COMPDAT" in schedule
|
|
assert "WCONHIST" in schedule
|
|
assert "WELTARG" in schedule
|
|
assert "1 'JUL' 2024" in schedule, (
|
|
"REPORT date should be emitted as a DATES entry"
|
|
)
|
|
|
|
def test_apply_full_event_coverage_and_schedule(self, project_with_case_and_wells):
|
|
"""All event kinds from well_event_schedule.py expressed as ORIONEVENTS."""
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = next(wp for wp in project.well_paths() if "A" in wp.name)
|
|
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
"UNIT METRIC\n"
|
|
"DATE STARTUP = 2024-01-01\n"
|
|
"DURATION RAMP = 31 DAYS\n"
|
|
f'WELL "{well.name}"\n'
|
|
" @STARTUP TUBING MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 ROUGHNESS=1.0e-5\n"
|
|
" @STARTUP + RAMP PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1\n"
|
|
" @2024-05-15T14:45:30.500 PERFORATION MDSTART=2300 MDEND=2350 RADIUS=0.05 SKIN=0.4 COMPLETION_NUMBER=2\n"
|
|
" @2024-03-01 VALVE MD=2100 TYPE=ICV STATE=OPEN CV=0.7 AREA=0.0001\n"
|
|
" @2024-02-15 STATE STATE=OPEN\n"
|
|
" @2024-01-15 WCONHIST STATUS=OPEN CMODE=RESV ORAT=3999.99 VFP=1\n"
|
|
" @2024-05-01 WELTARG CMODE=ORAT VALUE=5000.0\n"
|
|
" @2024-06-01 WRFTPLT OUTPUT_RFT=YES OUTPUT_PLT=NO OUTPUT_SEGMENT=NO\n"
|
|
"SCHEDULE\n"
|
|
" @STARTUP RPTRST BASIC=2 FREQ=1\n"
|
|
" @STARTUP GRUPTREE CHILD_GROUP=OP PARENT_GROUP=FIELD\n"
|
|
" @STARTUP TUNING TSINIT=1 TSMAXZ=30 NEWTMX=12\n"
|
|
)
|
|
document = parse_orion_events(text)
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.errors == []
|
|
assert report.warnings == []
|
|
assert report.events_applied == 11
|
|
|
|
timeline.set_timestamp(timestamp="2024-12-24")
|
|
schedule = timeline.generate_schedule_text(
|
|
eclipse_case=case, export_msw_for_wells=[well]
|
|
)
|
|
for keyword in (
|
|
"COMPDAT",
|
|
"COMPLUMP",
|
|
"WCONHIST",
|
|
"WELTARG",
|
|
"WRFTPLT",
|
|
"RPTRST",
|
|
"GRUPTREE",
|
|
"TUNING",
|
|
):
|
|
assert keyword in schedule, f"{keyword} missing from schedule"
|
|
# The datetime perforation must surface as a DATES entry with TIME.
|
|
assert "14:45:30.500" in schedule
|
|
|
|
def test_apply_unknown_well_warns_and_applies_nothing(
|
|
self, project_with_case_and_wells
|
|
):
|
|
project, _case, timeline = project_with_case_and_wells
|
|
text = (
|
|
'ORIONEVENTS 2.0\nWELL "NO_SUCH_WELL"\n'
|
|
" @2024-01-01 PERFORATION MDSTART=1 MDEND=2\n"
|
|
)
|
|
document = parse_orion_events(text)
|
|
report = apply_orion_document(document, timeline, project)
|
|
assert report.events_applied == 0
|
|
assert any("NO_SUCH_WELL" in w for w in report.warnings)
|
|
|
|
def test_apply_filter_creates_combined_filter_on_perforation(
|
|
self, project_with_case_and_wells
|
|
):
|
|
"""FILTER declaration -> case-level combined filter attached to the perforation."""
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = next(wp for wp in project.well_paths() if "A" in wp.name)
|
|
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
'FILTER hiperm = "PERMX > 50.0"\n'
|
|
f'WELL "{well.name}"\n'
|
|
" @2024-01-01 PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 FILTER=hiperm\n"
|
|
)
|
|
document = parse_orion_events(text)
|
|
report = apply_orion_document(document, timeline, project, case=case)
|
|
assert report.errors == []
|
|
assert report.warnings == []
|
|
assert report.events_applied == 1
|
|
|
|
# The declared filter exists at case level under its declaration name.
|
|
combined = next(
|
|
f for f in case.data_filter_collection().filters() if f.name == "hiperm"
|
|
)
|
|
assert combined.combine_mode == "AND"
|
|
property_filter = combined.filters()[0]
|
|
assert property_filter.lower_bound == 50.0
|
|
# Only the lower bound was set; the upper bound keeps the result max
|
|
# (PERMX in TEST10K tops out at exactly 100.0).
|
|
assert property_filter.upper_bound == 100.0
|
|
|
|
# The filter survives materialization onto the perforation interval.
|
|
timeline.set_timestamp(timestamp="2024-01-15")
|
|
perforations = well.completions().perforations().perforations()
|
|
assert len(perforations) == 1
|
|
cell_filter = perforations[0].cell_filter()
|
|
assert cell_filter is not None
|
|
assert cell_filter.name == "hiperm"
|
|
|
|
def test_apply_inline_filter_gets_auto_derived_name(
|
|
self, project_with_case_and_wells
|
|
):
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = next(wp for wp in project.well_paths() if "A" in wp.name)
|
|
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
' @2024-01-01 PERFORATION MDSTART=2000 MDEND=2200 FILTER="static.PERMX >= 50"\n'
|
|
)
|
|
document = parse_orion_events(text)
|
|
report = apply_orion_document(document, timeline, project, case=case)
|
|
assert report.errors == []
|
|
assert report.events_applied == 1
|
|
|
|
filters = case.data_filter_collection().filters()
|
|
assert len(filters) == 1
|
|
# Anonymous inline filter: ResInsight auto-derives a descriptive name.
|
|
assert "PERMX" in filters[0].name
|
|
|
|
def test_apply_filter_missing_result_raises_and_applies_nothing(
|
|
self, project_with_case_and_wells
|
|
):
|
|
from rips.exception import RipsError
|
|
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = next(wp for wp in project.well_paths() if "A" in wp.name)
|
|
|
|
text = (
|
|
"ORIONEVENTS 2.0\n"
|
|
f'WELL "{well.name}"\n'
|
|
' @2024-01-01 PERFORATION MDSTART=2000 MDEND=2200 FILTER="NO_SUCH_RESULT > 1"\n'
|
|
)
|
|
document = parse_orion_events(text)
|
|
with pytest.raises(RipsError, match="NO_SUCH_RESULT"):
|
|
apply_orion_document(document, timeline, project, case=case)
|
|
assert case.data_filter_collection().filters() == []
|
|
|
|
def test_perf_event_filter_round_trip(self, project_with_case_and_wells):
|
|
"""Direct timeline API: add_filter / cell_filter on the perf event itself."""
|
|
project, case, timeline = project_with_case_and_wells
|
|
well = next(wp for wp in project.well_paths() if "A" in wp.name)
|
|
|
|
combined = case.data_filter_collection().add_combined_filter(
|
|
name="Round Trip", combine_mode="AND"
|
|
)
|
|
perf_event = timeline.add_perf_event(
|
|
event_date="2024-01-01",
|
|
well_path=well,
|
|
start_md=2000.0,
|
|
end_md=2200.0,
|
|
state="OPEN",
|
|
)
|
|
assert perf_event.cell_filter() is None
|
|
perf_event.add_filter(filter=combined)
|
|
cell_filter = perf_event.cell_filter()
|
|
assert cell_filter is not None
|
|
assert cell_filter.name == "Round Trip"
|