#14666 Simulator Events: Expand INSERT_DATE into insert-date events

INSERT_DATE statements are now expanded into one timeline event per
occurrence instead of a list of bare dates, so each generated date keeps
the COMMENT of its statement.

- New RimWellEventInsertDate (EventType::INSERT_DATE): a date-only event
  that generates no keyword, added with
  WellEventTimeline.add_insert_date_event(event_date, comment).
- The schedule generator writes the comment of an insert-date event as
  "--" lines directly below its DATES keyword.
- generate_schedule(_text) no longer takes additional_dates; insert-date
  events are part of the timeline, but keep the previous behavior of not
  being filtered by set_timestamp().
- SimulatorEventsDocument.report_dates and ApplyReport.report_dates are
  replaced by SimulatorEventsDocument.insert_date_events/insert_dates;
  merging files drops insert dates repeating both date and comment.
This commit is contained in:
Kristian Bendiksen committed 2026-09-25 14:35:45 +02:00
1 parent 70e10cb386
commit 8324af966c
18 files changed
+453 -177

No files matched your search

@@ -102,7 +102,7 @@ def main() -> None:
)
print(f" Events applied: {report.events_applied}")
print(f" Events skipped: {report.events_skipped}")
print(f" Report dates: {report.report_dates}")
print(f" Inserted dates: {len(document.insert_date_events)}")
for warning in report.warnings:
print(f" WARNING: {warning}")
for error in report.errors:
@@ -122,13 +122,12 @@ def main() -> None:
# Generate Eclipse schedule text from the timeline.
print("\n4. Generating Eclipse schedule text...")
if report.events_applied == 0 and not report.report_dates:
print(" No events or report dates found - skipping schedule generation.")
if report.events_applied == 0:
print(" No events found - skipping schedule generation.")
return
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=project.well_paths(),
additional_dates=report.report_dates,
align_columns=True,
)
if schedule_text:
@@ -25,8 +25,8 @@ It demonstrates the full event coverage of the format:
7. A GROUP-level MEMBER event expanded to one GRUPTREE record per member
8. SCHEDULE-level keyword events not tied to a well: RPTRST, GRUPTREE, TUNING
9. Multiline RAW_TEXT inserted at a chosen position without parsing its contents
10. Recurring INSERT_DATE events with explicit and implicit end dates, passed to
generate_schedule_text(additional_dates=...) as summary-report triggers
10. Recurring INSERT_DATE events with explicit and implicit end dates, expanded
into commented insert-date events acting as summary-report triggers
11. Schedule metadata, COMPORD generation and aligned-column output
The SIMEVENTS text is built inline with the name of the first well path in
@@ -114,9 +114,10 @@ WTRACER
/
END_RAW_TEXT
# Recurring inserted dates become bare DATES keywords. The first series ends at
# the last event; the second uses an explicit inclusive end date.
STARTUP INSERT_DATE EVERY=1mon
# Recurring inserted dates become bare DATES keywords, each with the COMMENT of
# its statement. The first series ends at the last event; the second uses an
# explicit inclusive end date.
STARTUP INSERT_DATE EVERY=1mon COMMENT="Monthly report"
2024-07-01 INSERT_DATE EVERY=3mon UNTIL="STARTUP + 365d"
"""
@@ -179,7 +180,7 @@ def main() -> None:
)
print(f" Events applied: {report.events_applied}")
print(f" Events skipped: {report.events_skipped}")
print(f" Inserted dates: {report.report_dates}")
print(f" Inserted dates: {len(document.insert_date_events)}")
for warning in report.warnings:
print(f" WARNING: {warning}")
for error in report.errors:
@@ -212,13 +213,12 @@ def main() -> None:
if case is None:
print(" No Eclipse case loaded - skipping schedule generation.")
return
# INSERT_DATE values become bare DATES keywords via additional_dates. Aligned output
# INSERT_DATE events become bare DATES keywords with their comment. Aligned output
# adds column-title comments; the schedule header identifies its timestamp and
# user, and each generated WELSPECS record has a matching COMPORD INPUT record.
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=[well_path],
additional_dates=report.report_dates,
align_columns=True,
)
if schedule_text:
@@ -251,12 +251,14 @@ 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
# Insert-date events are emitted as bare DATES keywords even when no other events
# fall on them - in Eclipse/Flow a DATES entry ensures a summary report.
timeline.add_insert_date_event(
event_date="2024-07-01", comment="Summary report only"
)
schedule_text = timeline.generate_schedule_text(
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
@@ -20,9 +20,10 @@ FILTER POROPERM = "PORO > 0.1 AND PERMX > 100.0"
WELL A1 = "55_33-A-1"
# Inserted dates become bare DATES keywords in the generated schedule. Recurring
# dates may use an explicit inclusive end date or default to the last event.
# dates may use an explicit inclusive end date or default to the last event, and
# a COMMENT is repeated below every generated date.
SCHEDULE
A1_STARTUP INSERT_DATE EVERY=1mon
A1_STARTUP INSERT_DATE EVERY=1mon COMMENT="Monthly report"
A2_STARTUP INSERT_DATE EVERY=30d UNTIL="A2_STARTUP + 90d"
# Raw schedule text is preserved verbatim. This block is inserted immediately
+88 -35
View File
@@ -128,13 +128,14 @@ Notes on the grammar:
is used. Occurrence ``n`` is the start date plus ``n`` times the month part,
then plus ``n`` times the fixed part, so monthly and yearly recurrences stay
anchored to the initial calendar day, clamping to the end of shorter
months. ``EVERY`` must be positive. ``COMMENT`` is accepted and ignored.
The dates are collected on :attr:`SimulatorEventsDocument.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=...)``. An
``INSERT_DATE`` event is not tied to any well and does not create a
timeline event.
months. ``EVERY`` must be positive. ``COMMENT`` is written as a ``--`` comment
directly below each generated date.
A statement is expanded into one insert-date event per occurrence, each
keeping the ``COMMENT`` of the statement; they are collected on
:attr:`SimulatorEventsDocument.insert_date_events`. The applier turns each
into a timeline event with ``WellEventTimeline.add_insert_date_event()``, so
``generate_schedule_text()`` emits the dates without further arguments. An
``INSERT_DATE`` event is not tied to any well and generates no keyword.
* ``RAW_TEXT`` is valid only inside a ``SCHEDULE`` block. Its body is copied
without parsing or formatting through the mandatory standalone
``END_RAW_TEXT`` line. ``PLACEMENT`` is ``AFTER_DATE``, ``BEFORE_KEYWORD``,
@@ -193,7 +194,7 @@ import datetime
import difflib
import os
import re
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from .exception import RipsError
@@ -465,12 +466,19 @@ class GroupBlock:
@dataclass(frozen=True)
class _ReportSpec:
"""One INSERT_DATE declaration, expanded after all event dates are known."""
"""One INSERT_DATE event, expanded after all event dates are known."""
start: datetime.datetime
event: SimulatorEvent
every: Optional[Duration]
end: Optional[datetime.datetime]
loc: SourceLoc
@property
def start(self) -> datetime.datetime:
return self.event.event_date
@property
def loc(self) -> SourceLoc:
return self.event.loc
@dataclass
@@ -483,10 +491,15 @@ class SimulatorEventsDocument:
wells: List[WellBlock] = field(default_factory=list)
groups: List[GroupBlock] = field(default_factory=list)
schedule_events: List[SimulatorEvent] = field(default_factory=list)
report_dates: List[datetime.datetime] = field(default_factory=list)
insert_date_events: List[SimulatorEvent] = field(default_factory=list)
warnings: List[ParseWarning] = field(default_factory=list)
source: Optional[str] = None
@property
def insert_dates(self) -> List[datetime.datetime]:
"""The dates of the expanded ``INSERT_DATE`` events, in document order."""
return [event.event_date for event in self.insert_date_events]
def _iso_event_date(event_date: datetime.datetime) -> str:
"""Format an event date for the timeline API.
@@ -702,7 +715,6 @@ def merge_simulator_events_documents(
version=max((document.version for document in sources), key=_version_key),
unit_system=first.unit_system,
)
report_dates: Set[datetime.datetime] = set()
for document in sources:
result.warnings.extend(document.warnings)
for name, value in document.variables.items():
@@ -724,8 +736,7 @@ def merge_simulator_events_documents(
result.wells.extend(document.wells)
result.groups.extend(document.groups)
result.schedule_events.extend(document.schedule_events)
report_dates.update(document.report_dates)
result.report_dates = sorted(report_dates)
result.insert_date_events.extend(document.insert_date_events)
errors = _restart_validation_issues(
result.wells, result.groups, result.schedule_events
@@ -836,7 +847,7 @@ def parse_simulator_events(
_set_event_scopes(wells, groups, schedule_events)
errors.extend(_restart_validation_issues(wells, groups, schedule_events))
errors.extend(_wellspec_validation_issues(wells))
report_dates, report_errors = _expand_report_specs(
insert_date_events, report_errors = _expand_report_specs(
report_specs, wells, groups, schedule_events
)
errors.extend(report_errors)
@@ -850,7 +861,7 @@ def parse_simulator_events(
wells=wells,
groups=groups,
schedule_events=schedule_events,
report_dates=report_dates,
insert_date_events=insert_date_events,
warnings=warnings,
source=source,
)
@@ -872,17 +883,32 @@ def _expand_report_specs(
wells: List[WellBlock],
groups: List[GroupBlock],
schedule_events: List[SimulatorEvent],
) -> Tuple[List[datetime.datetime], List[ParseIssue]]:
) -> Tuple[List[SimulatorEvent], List[ParseIssue]]:
"""Expand every ``INSERT_DATE`` spec into one event per occurrence.
Each occurrence is a copy of the source event, so an attached ``COMMENT``
is kept for every date of a recurring series.
"""
event_dates = [event.event_date for well in wells for event in well.events]
event_dates.extend(event.event_date for group in groups for event in group.events)
event_dates.extend(event.event_date for event in schedule_events)
last_event_date = max(event_dates) if event_dates else None
dates: List[datetime.datetime] = []
def occurrence_event(
spec: _ReportSpec, event_date: datetime.datetime
) -> SimulatorEvent:
attributes = {
name: value
for name, value in spec.event.attributes.items()
if name == "COMMENT"
}
return replace(spec.event, event_date=event_date, attributes=attributes)
events: List[SimulatorEvent] = []
issues: List[ParseIssue] = []
for spec in report_specs:
if spec.every is None:
dates.append(spec.start)
events.append(occurrence_event(spec, spec.start))
continue
end = spec.end if spec.end is not None else last_event_date
@@ -910,10 +936,10 @@ def _expand_report_specs(
break
if value > end:
break
dates.append(value)
events.append(occurrence_event(spec, value))
occurrence += 1
return dates, issues
return events, issues
def _restart_validation_issues(
@@ -1249,7 +1275,7 @@ def _insert_date_spec(
)
end = _eval_date_expr(match.group("base"), match.group("terms"), variables, loc)
return _ReportSpec(start=event.event_date, every=every, end=end, loc=loc)
return _ReportSpec(event=event, every=every, end=end)
def _unrecognized_line_message(line: str, first: str) -> str:
@@ -1714,7 +1740,6 @@ 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)
@@ -1851,7 +1876,8 @@ def coalesce_simulator_events_document(
applied in source order. Well keyword attributes are then carried forward
chronologically to later events of the same type. Events that create or
expand domain objects are kept separate so, for example, same-date
perforation intervals are not lost.
perforation intervals are not lost. Inserted dates repeating both date and
comment are reduced to one event.
"""
result = copy.deepcopy(document)
@@ -1947,9 +1973,23 @@ def coalesce_simulator_events_document(
block.events = merge_events(block.events)
return merged_blocks
def merge_insert_date_events(events: List[SimulatorEvent]) -> List[SimulatorEvent]:
"""Drop repeated occurrences of the same date and comment."""
merged: List[SimulatorEvent] = []
seen: Set[Tuple[datetime.datetime, str]] = set()
for event in events:
comment = event.attributes.get("COMMENT")
key = (event.event_date, "" if comment is None else str(comment.value))
if key in seen:
continue
seen.add(key)
merged.append(event)
return merged
result.wells = merge_well_blocks(result.wells)
result.groups = merge_group_blocks(result.groups)
result.schedule_events = merge_events(result.schedule_events)
result.insert_date_events = merge_insert_date_events(result.insert_date_events)
return result
@@ -2057,17 +2097,15 @@ def apply_simulator_events_document(
event types are passed through as generic Eclipse keywords.
Returns:
ApplyReport: counts plus collected warnings/errors. ``INSERT_DATE`` 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.
ApplyReport: counts plus collected warnings/errors. Each ``INSERT_DATE``
occurrence becomes an insert-date event on the timeline, so its date
is emitted as a DATES keyword (with its comment, if any) by
``timeline.generate_schedule_text()``.
"""
_validate_policy(on_unknown_well, "on_unknown_well")
_validate_policy(on_unknown_event, "on_unknown_event")
document = coalesce_simulator_events_document(document)
report = ApplyReport()
report.report_dates = sorted({_iso_event_date(d) for d in document.report_dates})
report.warnings.extend(
f"{_loc_label(warning.loc)}: {warning.message}" for warning in document.warnings
)
@@ -2114,6 +2152,9 @@ def apply_simulator_events_document(
for event in document.schedule_events:
_apply_schedule_event(event, timeline, report)
for event in document.insert_date_events:
_apply_insert_date_event(event, timeline, report)
return report
@@ -2375,6 +2416,18 @@ def _apply_schedule_event(
report.events_applied += 1
def _apply_insert_date_event(
event: SimulatorEvent, timeline: Any, report: ApplyReport
) -> None:
"""Apply one expanded ``INSERT_DATE`` occurrence, comment included."""
comment = event.attributes.get("COMMENT")
timeline.add_insert_date_event(
event_date=_iso_event_date(event.event_date),
comment="" if comment is None else str(comment.value),
)
report.events_applied += 1
def _validate_policy(value: str, name: str) -> None:
if value not in _POLICIES:
raise ValueError(f"{name} must be one of {_POLICIES}, got {value!r}")
@@ -2748,8 +2801,6 @@ def _apply_to_running_instance(document: SimulatorEventsDocument) -> int:
print(f" Events applied: {report.events_applied}")
print(f" Events skipped: {report.events_skipped}")
if report.report_dates:
print(f" Report dates: {', '.join(report.report_dates)}")
for warning in report.warnings:
print(f" Warning: {warning}")
for error in report.errors:
@@ -2798,13 +2849,15 @@ def _cli(argv: Optional[List[str]] = None) -> int:
event_count = sum(len(well.events) for well in document.wells)
group_event_count = sum(len(group.events) for group in document.groups)
insert_date_count = len(document.insert_date_events)
schedule_event_count = len(document.schedule_events)
print(f"{label}: OK (SIMEVENTS {document.version}, units {document.unit_system})")
print(
f" {len(document.variables)} variable(s), {len(document.wells)} "
f"well block(s), {event_count} well event(s), "
f"{len(document.groups)} group block(s), {group_event_count} group event(s), "
f"{len(document.schedule_events)} schedule event(s), "
f"{len(document.report_dates)} report date(s)"
f"{schedule_event_count} schedule event(s), "
f"{insert_date_count} inserted date(s)"
)
normalized = coalesce_simulator_events_document(document)
for warning in normalized.warnings:
@@ -195,6 +195,7 @@ class FakeTimeline:
self.wellspec_calls = []
self.schedule_keyword_calls = []
self.raw_text_calls = []
self.insert_date_calls = []
self.created_events = []
def add_perf_event(self, **kwargs):
@@ -236,6 +237,10 @@ class FakeTimeline:
self.raw_text_calls.append(kwargs)
return self._new_event()
def add_insert_date_event(self, **kwargs):
self.insert_date_calls.append(kwargs)
return self._new_event()
# ---------------------------------------------------------------------------
# Layer A: parsing
@@ -261,7 +266,7 @@ class TestParsing:
text = example["build_simulator_events_text"]("W", with_filter)
doc = parse_simulator_events(text)
assert doc.wells[0].well_name == "W"
assert doc.report_dates[-1] == datetime.datetime(2024, 10, 1)
assert doc.insert_dates[-1] == datetime.datetime(2024, 10, 1)
def test_date_declarations_with_day_offset(self):
doc = parse_simulator_events(SAMPLE)
@@ -381,7 +386,7 @@ class TestParsing:
'2024-01-01 + -1d INSERT_DATE EVERY=1d UNTIL="2024-01-05 + -2d"\n'
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2023, 12, 31),
datetime.datetime(2024, 1, 1),
datetime.datetime(2024, 1, 2),
@@ -719,7 +724,7 @@ class TestParsing:
"START + 31d INSERT_DATE\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 6, 1),
datetime.datetime(2024, 2, 1),
]
@@ -729,7 +734,7 @@ class TestParsing:
"SIMEVENTS 1.2\nSCHEDULE\n2024-06-01 INSERT_DATE\n2024-06-01 INSERT_DATE\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [datetime.datetime(2024, 6, 1)] * 2
assert doc.insert_dates == [datetime.datetime(2024, 6, 1)] * 2
def test_insert_date_is_only_valid_in_schedule_block(self):
with pytest.raises(SimulatorEventsParseError, match="only valid in a SCHEDULE"):
@@ -755,7 +760,7 @@ class TestParsing:
def test_report_with_datetime_literal(self):
text = "SIMEVENTS 1.2\nSCHEDULE\n2024-06-01T14:45:30.700 INSERT_DATE\n"
doc = parse_simulator_events(text)
assert doc.report_dates == [datetime.datetime(2024, 6, 1, 14, 45, 31)]
assert doc.insert_dates == [datetime.datetime(2024, 6, 1, 14, 45, 31)]
def test_daily_report_recurrence_with_inclusive_until(self):
text = (
@@ -766,7 +771,7 @@ class TestParsing:
"START + 1d INSERT_DATE EVERY=2d UNTIL=END\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 1, 2),
datetime.datetime(2024, 1, 4),
]
@@ -777,7 +782,7 @@ class TestParsing:
"2024-01-31 INSERT_DATE EVERY=1mon UNTIL=2024-04-30\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 1, 31),
datetime.datetime(2024, 2, 29),
datetime.datetime(2024, 3, 31),
@@ -790,7 +795,7 @@ class TestParsing:
"2024-02-29 INSERT_DATE EVERY=12mon UNTIL=2028-02-29\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 2, 29),
datetime.datetime(2025, 2, 28),
datetime.datetime(2026, 2, 28),
@@ -807,7 +812,7 @@ class TestParsing:
" 2024-03-15 WCONHIST STATUS=OPEN\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 1, 1),
datetime.datetime(2024, 2, 1),
datetime.datetime(2024, 3, 1),
@@ -821,7 +826,7 @@ class TestParsing:
"UNTIL=2024-06-02T14:45:30.700\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 6, 1, 14, 45, 31),
datetime.datetime(2024, 6, 2, 14, 45, 31),
]
@@ -848,7 +853,7 @@ class TestParsing:
+ "SCHEDULE\n"
+ "".join(f" {line}\n" for line in lines)
)
return parse_simulator_events(text).report_dates
return parse_simulator_events(text).insert_dates
def test_insert_date_every_accepts_duration_variable(self):
dates = self._insert_dates(
@@ -914,30 +919,37 @@ class TestParsing:
dates = self._insert_dates("2024-01-01 insert_date every=1d until=2024-01-02")
assert dates == [datetime.datetime(2024, 1, 1), datetime.datetime(2024, 1, 2)]
def test_insert_date_comment_is_accepted_and_ignored(self):
def test_insert_date_comment_is_kept_for_every_occurrence(self):
text = (
"SIMEVENTS 1.2\nSCHEDULE\n"
' 2024-01-01 INSERT_DATE EVERY=1d UNTIL=2024-01-02 COMMENT="Daily"\n'
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 1, 1),
datetime.datetime(2024, 1, 2),
]
# Each occurrence keeps the comment, and nothing else, of the statement.
for event in doc.insert_date_events:
assert set(event.attributes) == {"COMMENT"}
assert event.attributes["COMMENT"].value == "Daily"
assert doc.schedule_events == []
assert doc.warnings == []
def test_insert_date_does_not_create_schedule_event(self):
def test_insert_date_creates_insert_date_events_only(self):
text = (
"SIMEVENTS 1.2\nSCHEDULE\n"
" 2024-01-01 RPTRST BASIC=1\n"
" 2024-02-01 INSERT_DATE\n"
' 2024-02-01 INSERT_DATE COMMENT="Report"\n'
)
doc = parse_simulator_events(text)
assert [event.event_type for event in doc.schedule_events] == ["RPTRST"]
report = apply_simulator_events_document(doc, FakeTimeline(), FakeProject(()))
assert report.events_applied == 1
assert report.report_dates == ["2024-02-01"]
timeline = FakeTimeline()
report = apply_simulator_events_document(doc, timeline, FakeProject(()))
assert report.events_applied == 2
assert timeline.insert_date_calls == [
{"event_date": "2024-02-01", "comment": "Report"}
]
@pytest.mark.parametrize(
("attributes", "message"),
@@ -1215,7 +1227,7 @@ class TestDurations:
"2024-01-31 + STEP INSERT_DATE EVERY=1d UNTIL=2024-01-31+STEP+1d\n"
)
doc = parse_simulator_events(text)
assert doc.report_dates == [
assert doc.insert_dates == [
datetime.datetime(2024, 2, 29),
datetime.datetime(2024, 3, 1),
]
@@ -1767,20 +1779,21 @@ class TestApplying:
assert timeline.keyword_calls[0]["event_date"] == "2018-01-01"
assert not report.warnings
def test_report_dates_on_apply_report(self):
def test_insert_dates_become_timeline_events(self):
text = (
'SIMEVENTS 1.2\nWELL "55_33-A-1"\n'
" 2018-01-01 WCONHIST STATUS=OPEN\n"
"SCHEDULE\n"
"2018-07-01 INSERT_DATE\n"
"2018-03-01 INSERT_DATE\n"
"2018-07-01 INSERT_DATE\n"
'2018-03-01 INSERT_DATE COMMENT="Mid-year"\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
# One timeline event per inserted date, in document order.
assert timeline.insert_date_calls == [
{"event_date": "2018-07-01", "comment": ""},
{"event_date": "2018-03-01", "comment": "Mid-year"},
]
assert report.events_applied == 3
def test_radius_on_perforation_is_unknown_attribute_error(self):
text = (
@@ -2346,7 +2359,8 @@ class TestMultipleFiles:
assert len(merged.schedule_events) == 1
assert set(merged.schedule_events[0].attributes) == {"BASIC", "FREQ"}
assert merged.report_dates == [
# The repeated 2018-02-01 insert date is reduced to one event.
assert merged.insert_dates == [
datetime.datetime(2018, 2, 1),
datetime.datetime(2018, 3, 1),
]
@@ -2491,8 +2505,9 @@ class TestMultipleFiles:
out = capsys.readouterr().out
assert f"{files[0]}, {files[1]}: OK" in out
assert "2 well block(s), 4 well event(s)" in out
# One merged WCONHIST, two perforations, one GCONPROD and one RPTRST.
assert "Events applied: 5" in out
# One merged WCONHIST, two perforations, one GCONPROD, one RPTRST and
# two inserted dates (the repeated 2018-02-01 is applied once).
assert "Events applied: 7" in out
assert len(timeline.keyword_calls) == 1
assert len(timeline.perf_calls) == 2
@@ -2881,7 +2896,6 @@ class TestSimulatorEventsIntegration:
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
@@ -2893,23 +2907,30 @@ class TestSimulatorEventsIntegration:
assert " 300" in schedule
assert "RESTART\n" not in schedule
def test_report_only_document_generates_schedule(self, project_with_case_and_wells):
def test_insert_date_only_document_generates_schedule(
self, project_with_case_and_wells
):
project, case, timeline = project_with_case_and_wells
document = parse_simulator_events(
"SIMEVENTS 1.2\nSCHEDULE\n2024-02-01 INSERT_DATE\n2024-06-01 INSERT_DATE\n"
"SIMEVENTS 1.2\nSCHEDULE\n"
'2024-02-01 INSERT_DATE EVERY=1mon UNTIL=2024-04-01 COMMENT="Quarterly"\n'
"2024-06-01 INSERT_DATE\n"
)
report = apply_simulator_events_document(document, timeline, project)
schedule = timeline.generate_schedule_text(
eclipse_case=case,
first_date_as_comment=False,
additional_dates=report.report_dates,
)
assert report.events_applied == 0
assert report.report_dates == ["2024-02-01", "2024-06-01"]
# Three occurrences of the recurring statement plus the single date.
assert report.events_applied == 4
assert "1 'FEB' 2024" in schedule
assert "1 'MAR' 2024" in schedule
assert "1 'APR' 2024" in schedule
assert "1 'JUN' 2024" in schedule
# The comment of the statement follows every generated date.
assert schedule.count("-- Quarterly") == 3
def test_end_is_first_keyword_after_date(self, project_with_case_and_wells):
project, case, timeline = project_with_case_and_wells
@@ -2952,8 +2973,7 @@ class TestSimulatorEventsIntegration:
document = parse_simulator_events(text)
report = apply_simulator_events_document(document, timeline, project)
assert report.errors == []
assert report.events_applied == 3
assert report.report_dates == ["2024-07-01"]
assert report.events_applied == 4
# Materialize completions from the perforation event.
timeline.set_timestamp(timestamp="2024-01-15")
@@ -2970,7 +2990,6 @@ class TestSimulatorEventsIntegration:
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
@@ -444,8 +444,10 @@ 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)."""
def test_generate_schedule_with_insert_date_events(
self, project_with_case_and_well
):
"""Insert-date events become bare DATES keywords, merged chronologically (issue #14514)."""
project, case, timeline = project_with_case_and_well
well_paths = project.well_paths()
@@ -467,20 +469,26 @@ class TestScheduleGeneration:
diameter=0.1,
state="OPEN",
)
timeline.add_insert_date_event(event_date="2024-02-01")
timeline.add_insert_date_event(
event_date="2024-06-01", comment="Mid-year summary report"
)
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"
"Inserted 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"
"Inserted date 2024-06-01 should be emitted as a DATES entry"
)
assert "-- Mid-year summary report" in schedule_text, (
"The comment of an insert-date event should be emitted below its date"
)
# Dates must appear in chronological order: JAN (event), FEB, MAR (event), JUN
positions = [
@@ -496,8 +504,8 @@ class TestScheduleGeneration:
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."""
def test_insert_dates_deduplicated_and_merged(self, project_with_case_and_well):
"""An inserted 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()
@@ -511,46 +519,30 @@ class TestScheduleGeneration:
diameter=0.1,
state="OPEN",
)
timeline.add_insert_date_event(event_date="2024-01-01")
timeline.add_insert_date_event(event_date="2024-01-01")
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"
"Duplicate inserted 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")
def test_insert_date_invalid_format(self, project_with_case_and_well):
"""An unparsable inserted date must raise an error mentioning the format."""
_, _, timeline = project_with_case_and_well
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"],
)
timeline.add_insert_date_event(event_date="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."""
def test_insert_date_before_first_event(self, project_with_case_and_well):
"""An inserted 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()
@@ -564,17 +556,17 @@ class TestScheduleGeneration:
diameter=0.1,
state="OPEN",
)
timeline.add_insert_date_event(event_date="2023-06-01")
timeline.set_timestamp(timestamp="2024-12-31")
# Default first_date_as_comment=True: the earliest date (the additional
# Default first_date_as_comment=True: the earliest date (the inserted
# 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"
"Earliest (inserted) 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"
@@ -585,10 +577,9 @@ class TestScheduleGeneration:
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"
"Inserted date should be a DATES entry with first_date_as_comment=False"
)
def test_first_date_as_comment(self, project_with_case_and_well):
+24 -11
View File
@@ -309,6 +309,24 @@ def add_raw_text_event(
)
@add_method(WellEventTimeline)
def add_insert_date_event(
self: WellEventTimeline,
event_date: str | date | datetime,
comment: str = "",
) -> Any:
"""Add a date that is emitted as a DATES keyword even without other events.
In Eclipse/Flow a DATES entry ensures a summary report at that date. The
event creates no keyword of its own; ``comment`` (if any) is written as
"--"-prefixed lines directly below the date.
"""
return self.add_insert_date_event_internal(
event_date=_format_date(event_date),
comment=comment,
)
@add_method(WellEventTimeline)
def generate_schedule_text(
self: WellEventTimeline,
@@ -316,7 +334,6 @@ 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.
@@ -341,15 +358,12 @@ 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.
Dates added with add_insert_date_event() are ordinary timeline events, but
since they only force a DATES keyword (e.g. to trigger a summary report)
they are not filtered by set_timestamp(). If such a date precedes all other
event dates it becomes the earliest date and is therefore emitted as a
comment when first_date_as_comment is True.
Returns:
str: Eclipse schedule text containing DATES, COMPDAT, WELSEGS, WCONPROD, etc.
@@ -394,7 +408,6 @@ 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)