#14666 Simulator Events: Align INSERT_DATE with other keyword events

INSERT_DATE is now a SCHEDULE event using KEY=VALUE attributes, and the file
format version is bumped to SIMEVENTS 1.2:

    <date-expr> INSERT_DATE [EVERY=<duration>] [UNTIL=<date-expr>]

- EVERY takes a duration literal, DURATION variable or expression
  (EVERY=1mon, EVERY=30d, EVERY=12mon, EVERY=12h) and must be positive.
  Occurrence n is start + n months (clamped), then + n times the fixed part,
  which preserves the dates of the previous DAY/MONTH/YEAR recurrences.
- UNTIL is an inclusive end date and requires EVERY; quote values with spaces.
- COMMENT is accepted and ignored; other attributes are rejected.
- SIMEVENTS 1.1 files and old-style INSERT_DATE lines are rejected with a
  migration hint showing the rewritten line.
This commit is contained in:
Kristian Bendiksen committed 2026-09-25 14:35:45 +02:00
1 parent cbd3d39a5c
commit 70e10cb386
4 files changed
+493 -284

No files matched your search

@@ -5,7 +5,7 @@ Example: the well_event_schedule.py timeline expressed as a SIMEVENTS file.
This is the SIMEVENTS counterpart to well_event_schedule.py: instead of
calling the WellEventTimeline API methods one by one, the same events are
written as SIMEVENTS 1.1 text (see rips/simulator_events.py for the grammar) and
written as SIMEVENTS 1.2 text (see rips/simulator_events.py for the grammar) and
applied in one go with rips.simulator_events.apply_simulator_events_document().
It demonstrates the full event coverage of the format:
@@ -25,7 +25,7 @@ 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 directives with explicit and implicit end dates, passed to
10. Recurring INSERT_DATE events with explicit and implicit end dates, passed to
generate_schedule_text(additional_dates=...) as summary-report triggers
11. Schedule metadata, COMPORD generation and aligned-column output
@@ -40,7 +40,7 @@ import rips
import rips.simulator_events
def build_simulator_events_text(well_name, with_filter):
def build_simulator_events_text(well_name: str, with_filter: bool) -> str:
# 'static.PORO' restricts the result lookup to STATIC_NATIVE results; an
# unqualified name would search STATIC_NATIVE, DYNAMIC_NATIVE, GENERATED.
filter_decl = 'FILTER HIPORO = "static.PORO > 0.15"\n' if with_filter else ""
@@ -51,7 +51,7 @@ def build_simulator_events_text(well_name, with_filter):
)
filter_ref = " FILTER=HIPORO" if with_filter else ""
return f"""\
SIMEVENTS 1.1
SIMEVENTS 1.2
UNIT METRIC
# Typed declarations
@@ -116,12 +116,12 @@ 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.
INSERT_DATE STARTUP EVERY MONTH
INSERT_DATE 2024-07-01 EVERY 3 MONTHS UNTIL STARTUP + 365d
STARTUP INSERT_DATE EVERY=1mon
2024-07-01 INSERT_DATE EVERY=3mon UNTIL="STARTUP + 365d"
"""
def main():
def main() -> None:
resinsight = rips.Instance.find()
project = resinsight.project
@@ -2,7 +2,7 @@
# Parsed by rips.simulator_events; see that module's docstring for the grammar.
# Validate with: python3 -m rips.simulator_events simulator_events.events
SIMEVENTS 1.1
SIMEVENTS 1.2
UNIT METRIC
# Typed declarations: dates, Go-style durations (e.g. 5d, 12h30m), well-name aliases, and
@@ -22,8 +22,8 @@ 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.
SCHEDULE
INSERT_DATE A1_STARTUP EVERY MONTH
INSERT_DATE A2_STARTUP EVERY 30 DAYS UNTIL A2_STARTUP + 90d
A1_STARTUP INSERT_DATE EVERY=1mon
A2_STARTUP INSERT_DATE EVERY=30d UNTIL="A2_STARTUP + 90d"
# Raw schedule text is preserved verbatim. This block is inserted immediately
# after the date declaration; lower priorities are emitted first when multiple
+134 -77
View File
@@ -21,18 +21,14 @@ It is split into two independent layers:
calls the ``WellEventTimeline`` API, performing all semantic mapping and
validation.
File format grammar, version 1.1 (EBNF-ish)::
File format grammar, version 1.2 (EBNF-ish)::
document = header , { statement } ;
header = "SIMEVENTS" , "1.1" ; (* first meaningful line *)
statement = unit_directive | declaration | insert_date_line | well_block_open
header = "SIMEVENTS" , "1.2" ; (* first meaningful line *)
statement = unit_directive | declaration | well_block_open
| group_block_open | schedule_block_open | event_line
| raw_text_event ;
unit_directive = "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ;
insert_date_line = "INSERT_DATE" , date_expr , [ recurrence ] ;
recurrence = "EVERY" , [ positive_integer ] , period ,
[ "UNTIL" , date_expr ] ;
period = "DAY" | "DAYS" | "MONTH" | "MONTHS" | "YEAR" | "YEARS" ;
declaration = date_decl | duration_decl | well_decl | filter_decl ;
date_decl = "DATE" , ident , "=" , date_expr ; (* DATE X = 2018-03-01 + 9d *)
@@ -49,6 +45,11 @@ File format grammar, version 1.1 (EBNF-ish)::
group_block_open = "GROUP" , quoted_string ; (* group keyword events *)
schedule_block_open = "SCHEDULE" ; (* well-less keyword events *)
event_line = date_expr , event_type , { attribute } ;
insert_date_event = date_expr , "INSERT_DATE" , (* SCHEDULE block only *)
[ "EVERY=" , duration_expr ] , [ "UNTIL=" , date_expr ] ,
[ "COMMENT=" , quoted_string ] ;
(* UNTIL requires EVERY; quote a value
containing spaces *)
raw_text_event = date_expr , "RAW_TEXT" , raw_text_attributes , newline,
{ raw_line , newline } , "END_RAW_TEXT" ;
raw_text_attributes = "PLACEMENT=" ,
@@ -77,7 +78,7 @@ Notes on the grammar:
* The format is line-oriented; every non-blank line is dispatched on its first
token: ``SIMEVENTS`` (once), ``UNIT``, ``DATE``, ``DURATION``, ``WELL``,
``GROUP``, ``SCHEDULE``, ``INSERT_DATE`` or an event date. Anything else is an
``GROUP``, ``SCHEDULE`` or an event date. Anything else is an
error. Keywords are uppercase and case-sensitive; duration units are
lowercase.
* Comments start with ``#`` (outside of double quotes) and run to end of line.
@@ -117,18 +118,23 @@ Notes on the grammar:
record per unique member, with the enclosing group as parent. A schedule may
contain one attribute-free ``RESTART`` event; it truncates generated schedule
output before its timestamp and is not itself emitted as a keyword.
* ``INSERT_DATE <date_expr>`` inside a ``SCHEDULE`` block names a date that
* ``<date_expr> INSERT_DATE`` inside a ``SCHEDULE`` block 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. ``EVERY [n] DAYS|MONTHS|YEARS`` makes it recurring and
an inclusive ``UNTIL <date_expr>`` sets the end date. When ``UNTIL`` is
omitted, the latest event date is used. Monthly and yearly recurrences
stay anchored to the initial calendar day, clamping to the end of shorter
months. The dates are collected on :attr:`SimulatorEventsDocument.report_dates` and
report at that date. ``EVERY=<duration>`` makes it recurring (``EVERY=1mon``,
``EVERY=30d``, ``EVERY=12mon``, ``EVERY=RAMP``), and an inclusive
``UNTIL=<date_expr>`` sets the end date (``UNTIL=START+90d`` or
``UNTIL="START + 90d"``). When ``UNTIL`` is omitted, the latest event date
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=...)``. A
``INSERT_DATE`` line is not tied to any well.
``WellEventTimeline.generate_schedule_text(additional_dates=...)``. An
``INSERT_DATE`` event is not tied to any well and does not create a
timeline event.
* ``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``,
@@ -462,8 +468,7 @@ class _ReportSpec:
"""One INSERT_DATE declaration, expanded after all event dates are known."""
start: datetime.datetime
interval: Optional[int]
period: Optional[str]
every: Optional[Duration]
end: Optional[datetime.datetime]
loc: SourceLoc
@@ -539,7 +544,6 @@ _KEYWORDS = (
"FILTER",
"GROUP",
"SCHEDULE",
"INSERT_DATE",
)
_IDENT = r"[A-Za-z_]\w*"
@@ -561,7 +565,8 @@ _DURATION_DECL_RE = re.compile(
rf"^DURATION\s+(?P<name>{_IDENT})\s*=\s*(?P<base>{_DURATION_LIT}|{_IDENT}){_TERMS}$"
)
_WELL_DECL_RE = re.compile(rf'^WELL\s+(?P<name>{_IDENT})\s*=\s*"(?P<well>[^"]*)"$')
_INSERT_DATE_RE = re.compile(
# Pre-1.2 INSERT_DATE line syntax, only recognized to suggest the new form.
_LEGACY_INSERT_DATE_RE = re.compile(
rf"^INSERT_DATE\s+{_DATE_BASE}{_TERMS}"
rf"(?:\s+EVERY\s+(?:(?P<count>\d+)\s+)?"
rf"(?P<period>DAY|DAYS|MONTH|MONTHS|YEAR|YEARS)"
@@ -587,6 +592,9 @@ _RESULT_TYPE_ALIASES = {
_WELL_BLOCK_RE = re.compile(rf'^WELL\s+(?:"(?P<qname>[^"]*)"|(?P<ref>{_IDENT}))$')
_GROUP_BLOCK_RE = re.compile(r'^GROUP\s+"(?P<name>[^"]*)"$')
_EVENT_RE = re.compile(rf"^{_DATE_BASE}{_TERMS}\s+(?P<rest>.+)$")
# Attribute values of INSERT_DATE: EVERY=<duration-expr>, UNTIL=<date-expr>.
_DURATION_EXPR_RE = re.compile(rf"^\s*(?P<base>{_DURATION_LIT}|{_IDENT}){_TERMS}\s*$")
_DATE_EXPR_RE = re.compile(rf"^\s*{_DATE_BASE}{_TERMS}\s*$")
_TERM_RE = re.compile(rf"([-+])\s*({_DURATION_LIT}|{_IDENT})")
_DURATION_COMPONENT_RE = re.compile(r"(?P<number>\d+(?:\.\d+)?)(?P<unit>[A-Za-z]*)")
_TIME_OF_DAY_RE = re.compile(r"\d{2}:\d{2}:\d{2}(?:\.\d+)?")
@@ -731,7 +739,7 @@ def merge_simulator_events_documents(
def parse_simulator_events(
text: str, *, source: Optional[str] = None
) -> SimulatorEventsDocument:
"""Parse SIMEVENTS 1.1 text into an :class:`SimulatorEventsDocument`.
"""Parse SIMEVENTS 1.2 text into an :class:`SimulatorEventsDocument`.
``source`` is an optional label (typically the file name) stored on every
:class:`SourceLoc` and on the document.
@@ -849,17 +857,14 @@ def parse_simulator_events(
def _report_occurrence(
start: datetime.datetime,
interval: int,
period: str,
occurrence: int,
start: datetime.datetime, every: Duration, occurrence: int
) -> datetime.datetime:
offset = interval * occurrence
if period == "DAY":
return start + datetime.timedelta(days=offset)
if period == "MONTH":
return _add_months(start, offset)
return _add_months(start, 12 * offset)
"""Return occurrence ``n`` of a series: ``start + n*months + n*delta``.
The month part is applied relative to ``start`` so a monthly series stays
anchored to its initial calendar day, clamping to shorter months.
"""
return _add_months(start, every.months * occurrence) + every.delta * occurrence
def _expand_report_specs(
@@ -876,16 +881,10 @@ def _expand_report_specs(
dates: List[datetime.datetime] = []
issues: List[ParseIssue] = []
for spec in report_specs:
if spec.period is None:
if spec.every is None:
dates.append(spec.start)
continue
if spec.interval is None or spec.interval <= 0:
issues.append(
ParseIssue("INSERT_DATE interval must be greater than zero", spec.loc)
)
continue
end = spec.end if spec.end is not None else last_event_date
if end is None:
issues.append(
@@ -906,9 +905,7 @@ def _expand_report_specs(
occurrence = 0
while True:
try:
value = _report_occurrence(
spec.start, spec.interval, spec.period, occurrence
)
value = _report_occurrence(spec.start, spec.every, occurrence)
except (OverflowError, ValueError):
break
if value > end:
@@ -991,7 +988,7 @@ def _wellspec_validation_issues(wells: List[WellBlock]) -> List[ParseIssue]:
return issues
_SUPPORTED_VERSION = "1.1"
_SUPPORTED_VERSION = "1.2"
def _check_version(version: str, loc: SourceLoc) -> None:
@@ -1000,10 +997,17 @@ def _check_version(version: str, loc: SourceLoc) -> None:
message = (
f"Unsupported SIMEVENTS version '{version}'; expected {_SUPPORTED_VERSION}"
)
if version == "1.1":
message += (
" (1.2 writes INSERT_DATE as a SCHEDULE event, e.g. "
"'INSERT_DATE 2024-01-01 EVERY 3 MONTHS' -> "
"'2024-01-01 INSERT_DATE EVERY=3mon')"
)
if version == "1.0":
message += (
" (1.1 requires a unit on every duration, e.g. '5 DAYS' -> '5d', "
"and 'T' between date and time)"
" (durations require a unit, e.g. '5 DAYS' -> '5d', dates use 'T' "
"between date and time, and INSERT_DATE is written as a SCHEDULE "
"event, e.g. '2024-01-01 INSERT_DATE EVERY=3mon')"
)
raise SimulatorEventsParseError(message, loc)
@@ -1051,38 +1055,7 @@ def _parse_line(
return schedule_events
if first == "INSERT_DATE":
if current_events is not schedule_events:
raise SimulatorEventsParseError(
"INSERT_DATE is only valid in a SCHEDULE block", loc
)
match = _INSERT_DATE_RE.match(line)
if match is None:
raise SimulatorEventsParseError(
f"Malformed INSERT_DATE line: {line!r} "
"(expected INSERT_DATE <date-expr> [EVERY [count] "
"DAYS|MONTHS|YEARS [UNTIL <date-expr>]])",
loc,
)
start = _eval_date_expr(
match.group("base"), match.group("terms"), variables, loc
)
period = match.group("period")
end_base = match.group("end_base")
end = (
_eval_date_expr(end_base, match.group("end_terms"), variables, loc)
if end_base is not None
else None
)
report_specs.append(
_ReportSpec(
start=start,
interval=int(match.group("count") or 1) if period else None,
period=period.rstrip("S") if period else None,
end=end,
loc=loc,
)
)
return current_events
raise SimulatorEventsParseError(_legacy_insert_date_message(line), loc)
if first == "DATE":
match = _DATE_DECL_RE.match(line)
@@ -1185,7 +1158,15 @@ def _parse_line(
)
if current_events is not None and _EVENT_RE.match(line):
current_events.append(_parse_event_line(line, variables, loc))
event = _parse_event_line(line, variables, loc)
if event.event_type.upper() == "INSERT_DATE":
if current_events is not schedule_events:
raise SimulatorEventsParseError(
"INSERT_DATE is only valid in a SCHEDULE block", loc
)
report_specs.append(_insert_date_spec(event, variables))
return current_events
current_events.append(event)
return current_events
if first[0].isdigit() and _EVENT_RE.match(line):
raise SimulatorEventsParseError(
@@ -1195,6 +1176,82 @@ def _parse_line(
raise SimulatorEventsParseError(_unrecognized_line_message(line, first), loc)
_INSERT_DATE_ATTRS = {"EVERY", "UNTIL", "COMMENT"}
_LEGACY_PERIOD_UNITS = {"DAY": ("d", 1), "MONTH": ("mon", 1), "YEAR": ("mon", 12)}
def _legacy_insert_date_message(line: str) -> str:
"""Explain the SIMEVENTS 1.2 INSERT_DATE form, rewriting the line if possible."""
message = (
"INSERT_DATE is written as a SCHEDULE event since SIMEVENTS 1.2: "
"<date-expr> INSERT_DATE [EVERY=<duration>] [UNTIL=<date-expr>]"
)
match = _LEGACY_INSERT_DATE_RE.match(line)
if match is None:
return message
start = (match.group("base") + match.group("terms")).strip()
parts = [start, "INSERT_DATE"]
period = match.group("period")
if period:
unit, factor = _LEGACY_PERIOD_UNITS[period.rstrip("S")]
parts.append(f"EVERY={int(match.group('count') or 1) * factor}{unit}")
if match.group("end_base"):
until = (match.group("end_base") + match.group("end_terms")).strip()
parts.append(f'UNTIL="{until}"' if " " in until else f"UNTIL={until}")
return f"{message}; write {' '.join(parts)!r}"
def _insert_date_spec(
event: SimulatorEvent, variables: Dict[str, SimulatorEventValue]
) -> _ReportSpec:
"""Build the report specification for an ``INSERT_DATE`` schedule event.
``EVERY`` is a duration expression and ``UNTIL`` an inclusive date
expression; quote either value when it contains spaces. ``COMMENT`` is
accepted but not used.
"""
loc = event.loc
unknown = set(event.attributes) - _INSERT_DATE_ATTRS
if unknown:
raise SimulatorEventsParseError(
f"Unknown INSERT_DATE attribute(s): {', '.join(sorted(unknown))} "
"(expected EVERY, UNTIL)",
loc,
)
every: Optional[Duration] = None
if "EVERY" in event.attributes:
raw = event.attributes["EVERY"].raw
match = _DURATION_EXPR_RE.match(raw)
if match is None:
raise SimulatorEventsParseError(
f"INSERT_DATE EVERY must be a duration, e.g. EVERY=1mon or "
f"EVERY=30d, got {raw!r}",
loc,
)
every = _eval_duration_expr(
match.group("base"), match.group("terms"), variables, loc
)
if every.months < 0 or every.delta < _ZERO_DELTA or not every:
raise SimulatorEventsParseError(
f"INSERT_DATE EVERY must be a positive duration, got {raw!r}", loc
)
end: Optional[datetime.datetime] = None
if "UNTIL" in event.attributes:
if every is None:
raise SimulatorEventsParseError("INSERT_DATE UNTIL requires EVERY", loc)
raw = event.attributes["UNTIL"].raw
match = _DATE_EXPR_RE.match(raw)
if match is None:
raise SimulatorEventsParseError(
f"INSERT_DATE UNTIL must be a date expression, got {raw!r}", loc
)
end = _eval_date_expr(match.group("base"), match.group("terms"), variables, loc)
return _ReportSpec(start=event.event_date, every=every, end=end, loc=loc)
def _unrecognized_line_message(line: str, first: str) -> str:
if first == "SIMEVENTS":
return "Duplicate SIMEVENTS header"
File diff suppressed because it is too large. Load diff