#14514 Add option to include list of dates into schedule file

GenerateSchedule gains an AdditionalDates parameter (ISO date strings). The dates are merged, deduplicated and sorted with the event dates and each becomes a DATES keyword even when no events fall on it, ensuring a summary report at that date in Eclipse/Flow. They are deliberately not filtered by the last applied timestamp.

The ORIONEVENTS 2.0 format gains a top-level REPORT <date-expr> directive (date variables and day arithmetic supported). Parsed dates are collected on OrionDocument.report_dates and surfaced by the applier as sorted ISO strings on ApplyReport.report_dates, ready to pass to generate_schedule_text(additional_dates=...).
This commit is contained in:
Kristian Bendiksen committed 2026-08-19 10:28:35 +02:00
1 parent 8c3df6076d
commit b3d0256941
9 files changed
+324 -9

No files matched your search

@@ -16,7 +16,9 @@ It demonstrates the full event coverage of the format:
3. Well keyword events: WCONHIST and WELTARG (with attribute translation) and
WRFTPLT (generic Eclipse well keyword pass-through)
4. SCHEDULE-level keyword events not tied to a well: RPTRST, GRUPTREE, TUNING
5. Generating Eclipse schedule text from the resulting timeline
5. REPORT dates, passed to generate_schedule_text(additional_dates=...) so
they appear as bare DATES keywords (summary-report triggers)
6. Generating Eclipse schedule text from the resulting timeline
The ORIONEVENTS text is built inline with the name of the first well path in
the project (like well_event_schedule.py, which uses wells[0]), so the example
@@ -74,6 +76,11 @@ SCHEDULE
@STARTUP RPTRST BASIC=2 FREQ=1
@STARTUP GRUPTREE CHILD=OP PARENT=FIELD
@STARTUP TUNING TSINIT=1 TSMAXZ=30 TMAXWC=1 NEWTMX=12 NEWTMN=1 LITMAX=50 LITMIN=1 MXWSIT=50 MXWPIT=50
# Report dates: emitted as bare DATES keywords so Eclipse/Flow writes a
# summary report at these dates even though no events fall on them.
REPORT 2024-07-01
REPORT STARTUP + 365
"""
@@ -114,6 +121,7 @@ def main():
)
print(f" Events applied: {report.events_applied}")
print(f" Events skipped: {report.events_skipped}")
print(f" Report dates: {report.report_dates}")
for warning in report.warnings:
print(f" WARNING: {warning}")
for error in report.errors:
@@ -139,8 +147,12 @@ def main():
if case is None:
print(" No Eclipse case loaded - skipping schedule generation.")
return
# REPORT dates from the ORIONEVENTS text become bare DATES keywords
# (summary-report triggers) via additional_dates.
schedule_text = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=[well_path]
eclipse_case=case,
export_msw_for_wells=[well_path],
additional_dates=report.report_dates,
)
if schedule_text:
print(f" Generated schedule text ({len(schedule_text)} characters)")
@@ -251,8 +251,12 @@ def main():
# Generate schedule text. Pass the wells that should get multi-segment-well
# keywords (WELSEGS, COMPSEGS, WSEGVALV, WSEGAICD); an empty list omits them.
# additional_dates are emitted as bare DATES keywords even when no events
# fall on them - in Eclipse/Flow a DATES entry ensures a summary report.
schedule_text = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=[well_path]
eclipse_case=case,
export_msw_for_wells=[well_path],
additional_dates=["2024-07-01"],
)
# Generate the same schedule with align_columns=True, which adds a "--"-prefixed
@@ -19,6 +19,11 @@ FILTER POROPERM = "PORO > 0.1 AND PERMX > 100.0"
WELL A1 = "55_33-A-1"
# Report dates: each becomes a bare DATES keyword in the generated schedule
# (in Eclipse/Flow a DATES entry ensures a summary report at that date).
REPORT 2018-07-01
REPORT A2_STARTUP + 90
WELL A1
@A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1 FILTER=POROPERM
@A1_STARTUP PERFORATION MDSTART=1664.28 MDEND=1674.18 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=2 FILTER=POROPERM
+50 -4
View File
@@ -22,9 +22,10 @@ File format grammar, version 2.0 (EBNF-ish)::
document = header , { statement } ;
header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *)
statement = unit_directive | declaration | well_block_open
statement = unit_directive | declaration | report_line | well_block_open
| schedule_block_open | event_line ;
unit_directive = "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ;
report_line = "REPORT" , date_expr ; (* REPORT 2024-06-01 *)
declaration = date_decl | duration_decl | well_decl | filter_decl ;
date_decl = "DATE" , ident , "=" , date_expr ; (* DATE X = 2018-03-01 + 9 *)
@@ -75,6 +76,14 @@ Notes on the grammar:
variables. A ``WELL`` line containing ``=`` is always a declaration. A bare
``SCHEDULE`` line opens a block of schedule-level keyword events not tied to
any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal.
* ``REPORT <date_expr>`` (one date per line, anywhere after the header) names
a date that should appear as a bare ``DATES`` keyword in the generated
schedule even when no events fall on it — in Eclipse/Flow a ``DATES`` entry
ensures a summary report at that date. The dates are collected on
:attr:`OrionDocument.report_dates` and surfaced by the applier as sorted ISO
strings on :attr:`ApplyReport.report_dates`, ready to pass to
``WellEventTimeline.generate_schedule_text(additional_dates=...)``. A
``REPORT`` line is not tied to any well and does not close an open block.
* Double quotes are used everywhere: well names, filter expressions and
attribute values, e.g. ``FILTER="SOIL > 0.8 AND PERMX > 200"``.
* Every attribute is ``KEY=VALUE``; bare positional tokens are rejected.
@@ -260,6 +269,9 @@ class OrionDocument:
variables: Dict[str, OrionValue] = field(default_factory=dict)
wells: List[WellBlock] = field(default_factory=list)
schedule_events: List[OrionEvent] = field(default_factory=list)
report_dates: List[Union[datetime.date, datetime.datetime]] = field(
default_factory=list
)
warnings: List[ParseWarning] = field(default_factory=list)
@@ -267,7 +279,16 @@ class OrionDocument:
# Layer A: pure parser
# ---------------------------------------------------------------------------
_KEYWORDS = ("ORIONEVENTS", "UNIT", "DATE", "DURATION", "WELL", "FILTER", "SCHEDULE")
_KEYWORDS = (
"ORIONEVENTS",
"UNIT",
"DATE",
"DURATION",
"WELL",
"FILTER",
"SCHEDULE",
"REPORT",
)
_IDENT = r"[A-Za-z_]\w*"
_ISO_DATE = r"\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?)?"
@@ -282,6 +303,7 @@ _DURATION_DECL_RE = re.compile(
r"(?:\s+(?:DAYS|days))?$"
)
_WELL_DECL_RE = re.compile(rf'^WELL\s+(?P<name>{_IDENT})\s*=\s*"(?P<well>[^"]*)"$')
_REPORT_RE = re.compile(rf"^REPORT\s+{_DATE_BASE}{_TERMS}$")
_FILTER_DECL_RE = re.compile(rf'^FILTER\s+(?P<name>{_IDENT})\s*=\s*"(?P<expr>[^"]*)"$')
_FILTER_SPLIT_RE = re.compile(r"\s+(AND|OR)\s+")
_NUMBER = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
@@ -321,6 +343,7 @@ def parse_orion_events(text: str) -> OrionDocument:
variables: Dict[str, OrionValue] = {}
wells: List[WellBlock] = []
schedule_events: List[OrionEvent] = []
report_dates: List[Union[datetime.date, datetime.datetime]] = []
warnings: List[ParseWarning] = []
errors: List[ParseIssue] = []
# Event lines append to the current sink: a WellBlock's event list or the
@@ -352,6 +375,7 @@ def parse_orion_events(text: str) -> OrionDocument:
variables,
wells,
schedule_events,
report_dates,
warnings,
current_events,
unit_holder,
@@ -377,6 +401,7 @@ def parse_orion_events(text: str) -> OrionDocument:
variables=variables,
wells=wells,
schedule_events=schedule_events,
report_dates=report_dates,
warnings=warnings,
)
@@ -404,6 +429,7 @@ def _parse_line(
variables: Dict[str, OrionValue],
wells: List[WellBlock],
schedule_events: List[OrionEvent],
report_dates: List[Union[datetime.date, datetime.datetime]],
warnings: List[ParseWarning],
current_events: Optional[List[OrionEvent]],
unit_holder: List[str],
@@ -435,6 +461,19 @@ def _parse_line(
)
return schedule_events
if first == "REPORT":
match = _REPORT_RE.match(line)
if match is None:
raise OrionParseError(
f"Malformed REPORT line: {line!r} "
"(expected REPORT <iso-date|DATE-var> [+|- <days|DURATION-var> ...])",
loc,
)
report_dates.append(
_eval_date_expr(match.group("base"), match.group("terms"), variables, loc)
)
return current_events
if first == "DATE":
match = _DATE_DECL_RE.match(line)
if match is None:
@@ -805,6 +844,7 @@ class ApplyReport:
events_applied: int = 0
events_skipped: int = 0
report_dates: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
errors: List[str] = field(default_factory=list)
@@ -890,11 +930,16 @@ def apply_orion_document(
event types are passed through as generic Eclipse keywords.
Returns:
ApplyReport: counts plus collected warnings/errors.
ApplyReport: counts plus collected warnings/errors. ``REPORT`` dates
from the document are returned as sorted, deduplicated ISO strings
on ``report_dates`` — they do not create timeline events; pass them
to ``timeline.generate_schedule_text(additional_dates=...)`` to
emit them as DATES keywords.
"""
_validate_policy(on_unknown_well, "on_unknown_well")
_validate_policy(on_unknown_event, "on_unknown_event")
report = ApplyReport()
report.report_dates = sorted({d.isoformat() for d in document.report_dates})
ctx = _prepare_filter_context(document, project, case)
@@ -1398,7 +1443,8 @@ def _cli(argv: Optional[List[str]] = None) -> int:
print(
f" {len(document.variables)} variable(s), {len(document.wells)} "
f"well block(s), {event_count} well event(s), "
f"{len(document.schedule_events)} schedule event(s)"
f"{len(document.schedule_events)} schedule event(s), "
f"{len(document.report_dates)} report date(s)"
)
for warning in document.warnings:
print(f" Warning line {warning.loc.line}: {warning.message}")
@@ -402,6 +402,48 @@ class TestParsing:
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'
@@ -722,6 +764,20 @@ class TestApplying:
assert timeline.keyword_calls[0]["event_date"] == "2018-01-01"
assert any("DSHIFT" in w for w in 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_warns_and_applies(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
@@ -1051,11 +1107,13 @@ class TestOrionEventsIntegration:
" @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")
@@ -1066,13 +1124,19 @@ class TestOrionEventsIntegration:
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.
# 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=[]
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."""
@@ -437,6 +437,153 @@ class TestScheduleGeneration:
assert "DATES" in schedule_text, "Schedule should contain DATES keyword"
assert "2024" in schedule_text, "Schedule should contain event dates"
def test_generate_schedule_with_additional_dates(self, project_with_case_and_well):
"""Additional dates become bare DATES keywords, merged chronologically (issue #14514)."""
project, case, timeline = project_with_case_and_well
well_paths = project.well_paths()
well_path_a = [wp for wp in well_paths if "A" in wp.name][0]
timeline.add_perf_event(
event_date="2024-01-01",
well_path=well_path_a,
start_md=1800.0,
end_md=2000.0,
diameter=0.1,
state="OPEN",
)
timeline.add_perf_event(
event_date="2024-03-01",
well_path=well_path_a,
start_md=2000.0,
end_md=2200.0,
diameter=0.1,
state="OPEN",
)
timeline.set_timestamp(timestamp="2024-12-31")
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=project.well_paths(),
first_date_as_comment=False,
additional_dates=["2024-02-01", "2024-06-01"],
)
assert "1 'FEB' 2024" in schedule_text, (
"Additional date 2024-02-01 should be emitted as a DATES entry"
)
assert "1 'JUN' 2024" in schedule_text, (
"Additional date 2024-06-01 should be emitted as a DATES entry"
)
# Dates must appear in chronological order: JAN (event), FEB, MAR (event), JUN
positions = [
schedule_text.index(date_str)
for date_str in [
"1 'JAN' 2024",
"1 'FEB' 2024",
"1 'MAR' 2024",
"1 'JUN' 2024",
]
]
assert positions == sorted(positions), (
f"Dates should appear chronologically, got positions {positions}"
)
def test_additional_dates_deduplicated_and_merged(self, project_with_case_and_well):
"""An additional date equal to an event date must not produce a duplicate DATES entry."""
project, case, timeline = project_with_case_and_well
well_paths = project.well_paths()
well_path_a = [wp for wp in well_paths if "A" in wp.name][0]
timeline.add_perf_event(
event_date="2024-01-01",
well_path=well_path_a,
start_md=1800.0,
end_md=2000.0,
diameter=0.1,
state="OPEN",
)
timeline.set_timestamp(timestamp="2024-12-31")
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=project.well_paths(),
first_date_as_comment=False,
additional_dates=["2024-01-01", "2024-01-01"],
)
assert schedule_text.count("1 'JAN' 2024") == 1, (
"Duplicate additional dates should be merged with the event date"
)
def test_additional_dates_invalid_format(self, project_with_case_and_well):
"""An unparsable additional date must raise an error mentioning the format."""
project, case, timeline = project_with_case_and_well
well_paths = project.well_paths()
well_path_a = [wp for wp in well_paths if "A" in wp.name][0]
timeline.add_perf_event(
event_date="2024-01-01",
well_path=well_path_a,
start_md=1800.0,
end_md=2000.0,
diameter=0.1,
state="OPEN",
)
timeline.set_timestamp(timestamp="2024-12-31")
with pytest.raises(rips.RipsError) as exc_info:
timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=project.well_paths(),
additional_dates=["not-a-date"],
)
assert "Invalid date format" in str(exc_info.value)
def test_additional_date_before_first_event(self, project_with_case_and_well):
"""An additional date earlier than all events becomes the first date of the schedule."""
project, case, timeline = project_with_case_and_well
well_paths = project.well_paths()
well_path_a = [wp for wp in well_paths if "A" in wp.name][0]
timeline.add_perf_event(
event_date="2024-01-01",
well_path=well_path_a,
start_md=1800.0,
end_md=2000.0,
diameter=0.1,
state="OPEN",
)
timeline.set_timestamp(timestamp="2024-12-31")
# Default first_date_as_comment=True: the earliest date (the additional
# one) becomes the comment; the event date is a real DATES entry.
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=project.well_paths(),
additional_dates=["2023-06-01"],
)
assert "-- Date: 1 JUN 2023" in schedule_text, (
"Earliest (additional) date should be emitted as a comment by default"
)
assert "1 'JAN' 2024" in schedule_text, (
"Event date should be a DATES entry when it is no longer first"
)
# With first_date_as_comment=False every date is a DATES entry.
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=project.well_paths(),
first_date_as_comment=False,
additional_dates=["2023-06-01"],
)
assert "1 'JUN' 2023" in schedule_text, (
"Additional date should be a DATES entry with first_date_as_comment=False"
)
def test_first_date_as_comment(self, project_with_case_and_well):
"""Test that the first (earliest) date can be emitted as a comment.
+11
View File
@@ -290,6 +290,7 @@ def generate_schedule_text(
export_msw_for_wells: List[WellPath] = [],
first_date_as_comment: bool = True,
align_columns: bool = False,
additional_dates: List[str] = [],
) -> str:
"""Generate Eclipse schedule text for all wells in the collection.
@@ -314,6 +315,15 @@ def generate_schedule_text(
align_columns (bool): When True, emit each keyword with a "--"-prefixed
column-header comment and right-aligned, fixed-width columns instead
of the compact default form. Defaults to False.
additional_dates (List[str]): Additional dates ("YYYY-MM-DD" or a full
ISO timestamp such as "2024-05-15T14:45:30") emitted as DATES
keywords even when no events fall on them. In Eclipse/Flow a DATES
entry ensures a summary report at that date. The dates are merged,
deduplicated and sorted together with the event dates, and are not
filtered by set_timestamp(). If an additional date precedes all
event dates it becomes the earliest date and is therefore emitted
as a comment when first_date_as_comment is True; pass
first_date_as_comment=False to emit every date as a DATES keyword.
Returns:
str: Eclipse schedule text containing DATES, COMPDAT, WELSEGS, WCONPROD, etc.
@@ -358,6 +368,7 @@ def generate_schedule_text(
export_msw_for_wells=export_msw_for_wells,
first_date_as_comment=first_date_as_comment,
align_columns=align_columns,
additional_dates=additional_dates,
)
if container and container.values:
return "".join(container.values)