Files
ResInsight/GrpcInterface/Python/rips/orion_events.py
T

1848 lines
65 KiB
Python

"""
ORIONEVENTS well-event-timeline parser and applier.
This module reads an ORIONEVENTS text file describing well completion and
control events over time, and applies them to a ResInsight project through the
existing :class:`WellEventTimeline` API (``add_perf_event``,
``add_well_keyword_event``, ...).
It is split into two independent layers:
* **Layer A - parser** (:func:`parse_orion_events` / :func:`parse_orion_events_file`):
a pure-Python tokenizer that turns the text into an intermediate
representation (:class:`OrionDocument`). It has no dependency on a running
ResInsight instance and can be unit-tested standalone. It doubles as a
standalone validator: ``python3 -m rips.orion_events <file.orion>``.
* **Layer B - applier** (:func:`apply_orion_document` / :func:`apply_orion_events_file`):
takes the intermediate representation plus a live ``rips`` project/timeline and
calls the ``WellEventTimeline`` API, performing all semantic mapping and
validation.
File format grammar, version 2.0 (EBNF-ish)::
document = header , { statement } ;
header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *)
statement = unit_directive | declaration | report_line | well_block_open
| group_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 *)
duration_decl = "DURATION" , ident , "=" , duration_expr ; (* DURATION RAMP = 5 DAYS *)
well_decl = "WELL" , ident , "=" , quoted_string ; (* WELL A1 = "55_33-A-1" *)
filter_decl = "FILTER" , ident , "=" , '"' , filter_expr , '"' ;
(* FILTER POROPERM = "PORO > 0.4 AND PERMX > 100.0" *)
filter_expr = filter_term , { ( "AND" | "OR" ) , filter_term } ;
(* one combine mode; mixing AND and OR is an error *)
filter_term = [ result_type , "." ] , ident , comp_op , number ;
comp_op = ">" | ">=" | "<" | "<=" ;
well_block_open = "WELL" , ( quoted_string | ident ) ; (* no "=" present *)
group_block_open = "GROUP" , quoted_string ; (* group keyword events *)
schedule_block_open = "SCHEDULE" ; (* well-less keyword events *)
event_line = "@" , date_expr , event_type , { attribute } ;
date_expr = ( iso_date | iso_datetime | date_ident ) , { sign , term } ;
duration_expr = ( integer | dur_ident ) , { sign , term } , [ "DAYS" | "days" ] ;
term = integer | dur_ident ; (* whole days *)
sign = "+" | "-" ;
iso_date = 4digit , "-" , 2digit , "-" , 2digit ;
iso_datetime = iso_date , "T" , 2digit , ":" , 2digit , ":" , 2digit ,
[ "." , digits ] ; (* 2024-05-15T14:45:30.500 *)
ident = letter_or_underscore , { word_char } ;
attribute = ident , "=" , ( quoted_string | bareword ) ;
comment = "#" , rest-of-line ; (* line or trailing *)
Notes on the grammar:
* The format is line-oriented; every non-blank line is dispatched on its first
token: ``ORIONEVENTS`` (once), ``UNIT``, ``DATE``, ``DURATION``, ``WELL``,
``GROUP``, ``SCHEDULE`` or ``@``. Anything else is an error. Keywords are
uppercase and case-sensitive (the ``DAYS`` suffix is also accepted as ``days``).
* Comments start with ``#`` (outside of double quotes) and run to end of line.
* Variables are **typed**: ``DATE``, ``DURATION`` (whole days), ``WELL``
(well-name alias) and ``FILTER`` (cell filter expression) declarations share
one namespace and must precede use.
Using a variable of the wrong type is an error that cites both the use and
the declaration site. Redeclaring a name with the same type warns and the
last value wins; redeclaring with a different type is an error.
* Date arithmetic is a chain of signed whole-day terms, each an integer or a
``DURATION`` variable: ``@START + RAMP - 2``. Whitespace around ``+``/``-``
is optional but conventional. An event date may carry a time-of-day
(``@2024-05-15T14:45:30.500``), which the schedule generator preserves as
the optional TIME field of the DATES keyword.
* ``WELL <ident>`` opens an event block for a declared ``WELL`` alias;
``WELL "<name>"`` opens a block for the literal well name and never consults
variables. A ``WELL`` line containing ``=`` is always a declaration. A bare
``GROUP "<name>"`` opens a block of group-level Eclipse keyword events; the
group name is injected as the ``GROUP`` item when each event is applied.
A bare ``SCHEDULE`` line opens a block of schedule-level keyword events not
tied to any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal.
``MEMBER MEMBERS="A,B"`` inside a GROUP block is shorthand for one GRUPTREE
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.
* ``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.
* Event types inside a WELL block are either the built-in completion events
``PERFORATION``, ``TUBING``, ``VALVE``, ``STATE`` and ``WELLSPEC``, or any
Eclipse well keyword (``WCONHIST``, ``WELTARG``, ``WRFTPLT``, ``WCONPROD``,
...), which
is passed through generically with the well name injected as WELL. Event
``WELLSPEC`` accepts partial updates to ``GROUP``, ``CROSSFLOW``, ``REFDEPTH``
and ``PHASE`` (OIL/GAS/WATER/LIQUID). Omitted values inherit the previous
WELLSPEC state, initially using the well's completion export settings. There
may be multiple WELLSPEC events for a well, but not at the same timestamp.
Each emits a WELSPECS record with the cumulative state. Event values are
materialized back onto completion settings by ``timeline.set_timestamp()``.
Event types inside a GROUP block are Eclipse group keywords with the group
name injected as GROUP. Event types inside a SCHEDULE block are Eclipse schedule
keywords passed through as-is. An event type that closely resembles a built-in
is treated as a typo per the ``on_unknown_event`` policy instead of being
passed through.
* On a PERFORATION event, ``FILTER=<name>`` references a declared ``FILTER``
variable and ``FILTER="<expr>"`` is an inline anonymous filter expression.
The applier materializes each used filter as a case-level combined data
filter and attaches it to the perforation event. A filter term is
``[TYPE.]NAME <op> NUMBER`` with ``>``, ``>=``, ``<`` or ``<=``; bounds are
inclusive, so ``>`` behaves as ``>=``. An unqualified result name is
searched in STATIC_NATIVE, then DYNAMIC_NATIVE, then GENERATED results; a
``TYPE.`` qualifier (``STATIC``/``DYNAMIC``/``GENERATED`` or the full
``*_NATIVE`` form, case-insensitive) restricts the search to that type.
* Any other attribute key parses; ``FILTER`` on events other than
PERFORATION is ignored with a warning when applied.
* The parser recovers per line and reports **all** errors in one pass: the
raised :class:`OrionParseError` carries one :class:`ParseIssue` per problem.
Unknown names come with "did you mean" suggestions where possible.
* ORIONEVENTS 1.x files (``SET`` variables, single-quoted well names) are not
supported; the header version is rejected with a pointer to this grammar.
"""
from __future__ import annotations
import copy
import datetime
import difflib
import os
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from .exception import RipsError
AttrScalar = Union[str, int, float, bool]
# ---------------------------------------------------------------------------
# Exceptions and intermediate representation (Layer A output)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SourceLoc:
"""Location of a construct in the source file (1-based line number)."""
line: int
text: str
@dataclass(frozen=True)
class ParseIssue:
"""A single fatal problem found while parsing."""
message: str
loc: Optional[SourceLoc]
class OrionParseError(Exception):
"""Raised for structural or type errors while parsing.
``errors`` holds one :class:`ParseIssue` per problem; a single parse pass
collects every bad line before raising.
"""
def __init__(
self,
message: Optional[str] = None,
loc: Optional[SourceLoc] = None,
errors: Optional[List[ParseIssue]] = None,
) -> None:
if errors is not None:
self.errors = list(errors)
else:
self.errors = [ParseIssue(message=message or "", loc=loc)]
self.loc = loc
lines = [
f"Line {issue.loc.line}: {issue.message}" if issue.loc else issue.message
for issue in self.errors
]
super().__init__("\n".join(lines))
@dataclass(frozen=True)
class ParseWarning:
"""A non-fatal issue encountered while parsing."""
message: str
loc: SourceLoc
@dataclass(frozen=True)
class FilterTerm:
"""One comparison in a filter expression: ``[TYPE.]NAME <op> value``.
``result_type`` is ``STATIC_NATIVE``, ``DYNAMIC_NATIVE`` or ``GENERATED``
when the term is qualified, or None to search all three (in that order).
"""
result_name: str
result_type: Optional[str]
op: str
value: float
@dataclass(frozen=True)
class FilterExpr:
"""A parsed filter expression: comparison terms with one combine mode."""
terms: Tuple[FilterTerm, ...]
combine_mode: str
raw: str
def __str__(self) -> str:
return self.raw
@dataclass(frozen=True)
class EventFilter:
"""A cell filter attached to a PERFORATION event.
``name`` is the FILTER declaration name, or None for an inline expression.
"""
name: Optional[str]
expr: FilterExpr
@dataclass(frozen=True)
class OrionValue:
"""A typed variable: kind is ``DATE``, ``DURATION``, ``WELL`` or ``FILTER``.
A ``DATE`` value is a :class:`datetime.date`, or a :class:`datetime.datetime`
when declared with a time-of-day. A ``FILTER`` value is a
:class:`FilterExpr`.
"""
kind: str
value: Union[datetime.date, datetime.datetime, int, str, FilterExpr]
loc: SourceLoc
@dataclass(frozen=True)
class AttrValue:
"""A single ``KEY=VALUE`` attribute with its type-inferred value."""
raw: str
value: AttrScalar
quoted: bool
@dataclass
class WellSpecState:
"""Fully resolved cumulative state for one WELLSPEC event."""
group: str
crossflow: bool
refdepth: Optional[float]
phase: str
@dataclass
class OrionEvent:
"""One event line in an enclosing WELL, GROUP or SCHEDULE block."""
event_type: str
event_date: Union[datetime.date, datetime.datetime]
attributes: Dict[str, AttrValue]
loc: SourceLoc
filter: Optional[EventFilter] = None
well_spec: Optional[WellSpecState] = None
@dataclass
class WellBlock:
"""A ``WELL`` block header followed by its events."""
well_name: str
events: List[OrionEvent] = field(default_factory=list)
loc: SourceLoc = SourceLoc(0, "")
@dataclass
class GroupBlock:
"""A ``GROUP`` block header followed by its keyword events."""
group_name: str
events: List[OrionEvent] = field(default_factory=list)
loc: SourceLoc = SourceLoc(0, "")
@dataclass
class OrionDocument:
"""Parsed, lossless representation of an ORIONEVENTS file."""
version: str
unit_system: str = "METRIC"
variables: Dict[str, OrionValue] = field(default_factory=dict)
wells: List[WellBlock] = field(default_factory=list)
groups: List[GroupBlock] = 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)
# ---------------------------------------------------------------------------
# Layer A: pure parser
# ---------------------------------------------------------------------------
_KEYWORDS = (
"ORIONEVENTS",
"UNIT",
"DATE",
"DURATION",
"WELL",
"FILTER",
"GROUP",
"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+)?)?"
_DATE_BASE = rf"(?P<base>{_ISO_DATE}|{_IDENT})"
_TERMS = rf"(?P<terms>(?:\s*[-+]\s*(?:\d+|{_IDENT}))*)"
_HEADER_RE = re.compile(r"^ORIONEVENTS\s+(?P<version>\d+\.\d+)$")
_UNIT_RE = re.compile(r"^UNIT\s+(?P<unit>METRIC|FIELD|LAB)$")
_DATE_DECL_RE = re.compile(rf"^DATE\s+(?P<name>{_IDENT})\s*=\s*{_DATE_BASE}{_TERMS}$")
_DURATION_DECL_RE = re.compile(
rf"^DURATION\s+(?P<name>{_IDENT})\s*=\s*(?P<base>\d+|{_IDENT}){_TERMS}"
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+)?"
_FILTER_TERM_RE = re.compile(
rf"^(?:(?P<qual>{_IDENT})\.)?(?P<name>{_IDENT})\s*"
rf"(?P<op>>=|<=|>|<)\s*(?P<value>{_NUMBER})$"
)
# Result-type qualifiers accepted in filter terms (case-insensitive).
_RESULT_TYPE_ALIASES = {
"STATIC": "STATIC_NATIVE",
"STATIC_NATIVE": "STATIC_NATIVE",
"DYNAMIC": "DYNAMIC_NATIVE",
"DYNAMIC_NATIVE": "DYNAMIC_NATIVE",
"GENERATED": "GENERATED",
}
_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"^@\s*{_DATE_BASE}{_TERMS}\s+(?P<rest>.+)$")
_TERM_RE = re.compile(rf"([-+])\s*(\d+|{_IDENT})")
_ATTR_RE = re.compile(r'(?P<key>[A-Za-z_]\w*)\s*=\s*(?:"(?P<qval>[^"]*)"|(?P<val>\S+))')
def parse_orion_events_file(path: Union[str, "os.PathLike[str]"]) -> OrionDocument:
"""Parse an ORIONEVENTS file from disk into an :class:`OrionDocument`."""
with open(path, "r", encoding="utf-8") as handle:
return parse_orion_events(handle.read())
def parse_orion_events(text: str) -> OrionDocument:
"""Parse ORIONEVENTS 2.0 text into an :class:`OrionDocument`.
Raises:
OrionParseError: carrying every error found in the file. A missing or
unsupported ``ORIONEVENTS`` header aborts immediately.
"""
version: Optional[str] = None
unit_holder = ["METRIC"] # mutable so _parse_line can update it
variables: Dict[str, OrionValue] = {}
wells: List[WellBlock] = []
groups: List[GroupBlock] = []
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
# document-level schedule_events list.
current_events: Optional[List[OrionEvent]] = None
for lineno, raw_line in enumerate(text.splitlines(), start=1):
loc = SourceLoc(line=lineno, text=raw_line)
line = _strip_comment(raw_line).strip()
if not line:
continue
# The first meaningful line must be the header; header problems are
# fatal since nothing else can be interpreted without a version.
if version is None:
match = _HEADER_RE.match(line)
if match is None:
raise OrionParseError(
"File must start with 'ORIONEVENTS <version>'", loc
)
version = match.group("version")
_check_version(version, loc)
continue
try:
current_events = _parse_line(
line,
loc,
variables,
wells,
groups,
schedule_events,
report_dates,
warnings,
current_events,
unit_holder,
)
except OrionParseError as exc:
errors.extend(exc.errors)
if line.split(None, 1)[0] in ("WELL", "GROUP", "SCHEDULE") or (
line.startswith("@") and current_events is None
):
# Suppress cascading errors from lines belonging to a broken
# (or missing) block: swallow them into a discarded list.
current_events = []
if version is None:
raise OrionParseError("Empty file: missing 'ORIONEVENTS' header")
errors.extend(_restart_validation_issues(wells, groups, schedule_events))
errors.extend(_wellspec_validation_issues(wells))
if errors:
raise OrionParseError(errors=errors)
return OrionDocument(
version=version,
unit_system=unit_holder[0],
variables=variables,
wells=wells,
groups=groups,
schedule_events=schedule_events,
report_dates=report_dates,
warnings=warnings,
)
def _restart_validation_issues(
wells: List[WellBlock],
groups: List[GroupBlock],
schedule_events: List[OrionEvent],
) -> List[ParseIssue]:
"""Validate placement, cardinality and shape of RESTART events."""
issues: List[ParseIssue] = []
for well_block in wells:
for event in well_block.events:
if event.event_type.upper() == "RESTART":
issues.append(
ParseIssue("RESTART is only valid in a SCHEDULE block", event.loc)
)
for group_block in groups:
for event in group_block.events:
if event.event_type.upper() == "RESTART":
issues.append(
ParseIssue("RESTART is only valid in a SCHEDULE block", event.loc)
)
restart_events = [
event for event in schedule_events if event.event_type.upper() == "RESTART"
]
for event in restart_events:
if event.attributes:
issues.append(ParseIssue("RESTART takes no attributes", event.loc))
for event in restart_events[1:]:
issues.append(
ParseIssue("Only one RESTART event is allowed per schedule", event.loc)
)
return issues
def _wellspec_validation_issues(wells: List[WellBlock]) -> List[ParseIssue]:
"""Reject multiple WELLSPEC events for one well at the same timestamp."""
seen: Dict[Tuple[str, Union[datetime.date, datetime.datetime]], OrionEvent] = {}
issues: List[ParseIssue] = []
for well in wells:
for event in well.events:
if event.event_type.upper() != "WELLSPEC":
continue
key = (well.well_name, event.event_date)
previous = seen.get(key)
if previous is not None:
issues.append(
ParseIssue(
f"WELLSPEC already defined for well '{well.well_name}' "
f"at this date (line {previous.loc.line})",
event.loc,
)
)
else:
seen[key] = event
return issues
def _check_version(version: str, loc: SourceLoc) -> None:
major = version.split(".")[0]
if major == "2":
return
if major == "1":
raise OrionParseError(
"ORIONEVENTS 1.x files are no longer supported; this parser requires "
"version 2.0 (typed DATE/DURATION/WELL declarations, WELL event "
"blocks, double-quoted names). See the rips.orion_events docstring "
"for the 2.0 grammar",
loc,
)
raise OrionParseError(
f"Unsupported ORIONEVENTS version '{version}'; expected 2.0", loc
)
def _parse_line(
line: str,
loc: SourceLoc,
variables: Dict[str, OrionValue],
wells: List[WellBlock],
groups: List[GroupBlock],
schedule_events: List[OrionEvent],
report_dates: List[Union[datetime.date, datetime.datetime]],
warnings: List[ParseWarning],
current_events: Optional[List[OrionEvent]],
unit_holder: List[str],
) -> Optional[List[OrionEvent]]:
"""Dispatch one non-header line; returns the current event sink."""
if line.startswith("@"):
if current_events is None:
raise OrionParseError(
"Event line found before any WELL or SCHEDULE block", loc
)
current_events.append(_parse_event_line(line, variables, loc))
return current_events
first = line.split(None, 1)[0]
if first == "UNIT":
match = _UNIT_RE.match(line)
if match is None:
raise OrionParseError(
f"Malformed UNIT line: {line!r} (expected UNIT METRIC|FIELD|LAB)", loc
)
unit_holder[0] = match.group("unit")
return current_events
if first == "GROUP":
match = _GROUP_BLOCK_RE.match(line)
if match is None:
raise OrionParseError(
f'Malformed GROUP line: {line!r} (expected GROUP "<group-name>")',
loc,
)
new_group = GroupBlock(group_name=match.group("name"), loc=loc)
groups.append(new_group)
return new_group.events
if first == "SCHEDULE":
if line != "SCHEDULE":
raise OrionParseError(
f"Malformed SCHEDULE line: {line!r} (SCHEDULE takes no arguments)", loc
)
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:
raise OrionParseError(
f"Malformed DATE declaration: {line!r} "
"(expected DATE NAME = <iso-date|DATE-var> [+|- <days|DURATION-var> ...])",
loc,
)
value = _eval_date_expr(
match.group("base"), match.group("terms"), variables, loc
)
_declare(
match.group("name"),
OrionValue("DATE", value, loc),
variables,
warnings,
loc,
)
return current_events
if first == "DURATION":
match = _DURATION_DECL_RE.match(line)
if match is None:
raise OrionParseError(
f"Malformed DURATION declaration: {line!r} "
"(expected DURATION NAME = <days|DURATION-var> [+|- ...] [DAYS])",
loc,
)
days = _eval_duration_expr(
match.group("base"), match.group("terms"), variables, loc
)
_declare(
match.group("name"),
OrionValue("DURATION", days, loc),
variables,
warnings,
loc,
)
return current_events
if first == "FILTER":
match = _FILTER_DECL_RE.match(line)
if match is None:
raise OrionParseError(
f"Malformed FILTER declaration: {line!r} "
'(expected FILTER NAME = "<result> <op> <number> [AND|OR ...]")',
loc,
)
expr = _parse_filter_expr(match.group("expr"), loc)
_declare(
match.group("name"),
OrionValue("FILTER", expr, loc),
variables,
warnings,
loc,
)
return current_events
if first == "WELL":
decl_match = _WELL_DECL_RE.match(line)
if decl_match is not None:
_declare(
decl_match.group("name"),
OrionValue("WELL", decl_match.group("well"), loc),
variables,
warnings,
loc,
)
return current_events
block_match = _WELL_BLOCK_RE.match(line)
if block_match is None:
raise OrionParseError(
f"Malformed WELL line: {line!r} (well names containing special "
'characters must be double-quoted, e.g. WELL "55_33-A-1")',
loc,
)
name = block_match.group("qname")
if name is None:
name = str(
_lookup_var(block_match.group("ref"), "WELL", variables, loc).value
)
new_well = WellBlock(well_name=name, loc=loc)
wells.append(new_well)
return new_well.events
raise OrionParseError(_unrecognized_line_message(line, first), loc)
def _unrecognized_line_message(line: str, first: str) -> str:
if first == "ORIONEVENTS":
return "Duplicate ORIONEVENTS header"
if first == "SET":
return (
"SET is ORIONEVENTS 1.x syntax; declare a typed variable instead, "
"e.g. DATE NAME = 2018-01-01"
)
if line.startswith("'"):
return (
"Single-quoted well names are ORIONEVENTS 1.x syntax; open a well "
'block with WELL "name"'
)
message = f"Unrecognized line: {line!r}"
close = difflib.get_close_matches(first, _KEYWORDS, n=1, cutoff=0.6)
if close:
message += f"; did you mean '{close[0]}'?"
return message
def _declare(
name: str,
value: OrionValue,
variables: Dict[str, OrionValue],
warnings: List[ParseWarning],
loc: SourceLoc,
) -> None:
existing = variables.get(name)
if existing is not None:
if existing.kind != value.kind:
raise OrionParseError(
f"'{name}' is already declared as {existing.kind} "
f"(line {existing.loc.line}); cannot redeclare as {value.kind}",
loc,
)
warnings.append(ParseWarning(f"Duplicate {value.kind} '{name}'", loc))
variables[name] = value
def _lookup_var(
name: str,
expected_kind: str,
variables: Dict[str, OrionValue],
loc: SourceLoc,
) -> OrionValue:
"""Resolve a typed variable reference; the single typed-error site."""
value = variables.get(name)
if value is None:
same_kind = [n for n, v in variables.items() if v.kind == expected_kind]
close = difflib.get_close_matches(
name, same_kind or list(variables), n=1, cutoff=0.6
)
hint = f"; did you mean '{close[0]}'?" if close else ""
raise OrionParseError(f"Unknown variable '{name}'{hint}", loc)
if value.kind != expected_kind:
raise OrionParseError(
f"Variable '{name}' is a {value.kind} (declared line {value.loc.line}) "
f"but a {expected_kind} is required here",
loc,
)
return value
def _eval_terms(terms: str, variables: Dict[str, OrionValue], loc: SourceLoc) -> int:
"""Evaluate a signed chain of whole-day terms to a net day count."""
total = 0
for match in _TERM_RE.finditer(terms):
sign, term = match.groups()
if term.isdigit():
days = int(term)
else:
value = _lookup_var(term, "DURATION", variables, loc).value
assert isinstance(value, int)
days = value
total += days if sign == "+" else -days
return total
def _eval_date_expr(
base: str, terms: str, variables: Dict[str, OrionValue], loc: SourceLoc
) -> Union[datetime.date, datetime.datetime]:
"""Evaluate an ISO date(time) or DATE variable plus optional signed day terms."""
result: Union[datetime.date, datetime.datetime]
if base[0].isdigit():
try:
if "T" in base:
result = datetime.datetime.fromisoformat(base)
else:
result = datetime.date.fromisoformat(base)
except ValueError as exc:
raise OrionParseError(f"Invalid date '{base}': {exc}", loc)
else:
value = _lookup_var(base, "DATE", variables, loc).value
assert isinstance(value, datetime.date)
result = value
return result + datetime.timedelta(days=_eval_terms(terms, variables, loc))
def _eval_duration_expr(
base: str, terms: str, variables: Dict[str, OrionValue], loc: SourceLoc
) -> int:
"""Evaluate an integer or DURATION variable plus optional signed day terms."""
if base.isdigit():
result = int(base)
else:
value = _lookup_var(base, "DURATION", variables, loc).value
assert isinstance(value, int)
result = value
return result + _eval_terms(terms, variables, loc)
def _parse_filter_expr(text: str, loc: SourceLoc) -> FilterExpr:
"""Parse a filter expression into typed comparison terms."""
stripped = text.strip()
if not stripped:
raise OrionParseError("Empty filter expression", loc)
parts = _FILTER_SPLIT_RE.split(stripped)
chunks = parts[0::2]
connectors = parts[1::2]
if len(set(connectors)) > 1:
raise OrionParseError(
"Filter expression mixes AND and OR; a combined filter has a "
"single combine mode",
loc,
)
combine_mode = connectors[0] if connectors else "AND"
terms = tuple(_parse_filter_term(chunk, loc) for chunk in chunks)
return FilterExpr(terms=terms, combine_mode=combine_mode, raw=stripped)
def _parse_filter_term(chunk: str, loc: SourceLoc) -> FilterTerm:
chunk = chunk.strip()
match = _FILTER_TERM_RE.match(chunk)
if match is None:
raise OrionParseError(_malformed_filter_term_message(chunk), loc)
qual = match.group("qual")
result_type: Optional[str] = None
if qual is not None:
result_type = _RESULT_TYPE_ALIASES.get(qual.upper())
if result_type is None:
close = difflib.get_close_matches(
qual.upper(), sorted(set(_RESULT_TYPE_ALIASES)), n=1, cutoff=0.6
)
hint = f"; did you mean '{close[0]}'?" if close else ""
raise OrionParseError(
f"Unknown result type '{qual}' in filter term {chunk!r}{hint}", loc
)
return FilterTerm(
result_name=match.group("name").upper(),
result_type=result_type,
op=match.group("op"),
value=float(match.group("value")),
)
def _malformed_filter_term_message(chunk: str) -> str:
if re.search(r"\b(and|or)\b", chunk):
return (
f"Malformed filter term {chunk!r}: combine keywords must be "
"uppercase AND / OR"
)
if re.search(r"(?<![<>])=", chunk):
return (
f"Malformed filter term {chunk!r}: only >, >=, < and <= are "
"supported in filter expressions (bounds are inclusive, so > "
"behaves as >=)"
)
return (
f"Malformed filter term {chunk!r} "
"(expected NAME <op> NUMBER with >, >=, < or <=, optionally TYPE.NAME)"
)
def _strip_comment(line: str) -> str:
"""Remove a ``#`` comment, honoring double-quoted spans."""
in_quote = False
result: List[str] = []
for char in line:
if char == '"':
in_quote = not in_quote
if char == "#" and not in_quote:
break
result.append(char)
return "".join(result)
def _parse_event_line(
line: str, variables: Dict[str, OrionValue], loc: SourceLoc
) -> OrionEvent:
match = _EVENT_RE.match(line)
if match is None:
raise OrionParseError(f"Malformed event line: {line!r}", loc)
event_date = _eval_date_expr(
match.group("base"), match.group("terms"), variables, loc
)
rest = match.group("rest").strip()
parts = rest.split(None, 1)
event_type = parts[0]
attr_str = parts[1] if len(parts) > 1 else ""
attributes = _parse_attributes(attr_str, loc)
event_filter: Optional[EventFilter] = None
if event_type.upper() == "PERFORATION" and "FILTER" in attributes:
event_filter = _resolve_event_filter(attributes["FILTER"], variables, loc)
return OrionEvent(
event_type=event_type,
event_date=event_date,
attributes=attributes,
loc=loc,
filter=event_filter,
)
def _resolve_event_filter(
attr: AttrValue, variables: Dict[str, OrionValue], loc: SourceLoc
) -> EventFilter:
"""Resolve a PERFORATION FILTER attribute to a declared or inline filter."""
if attr.quoted:
return EventFilter(name=None, expr=_parse_filter_expr(attr.raw, loc))
if re.fullmatch(_IDENT, attr.raw) is None:
raise OrionParseError(
"FILTER must name a declared FILTER variable or be a quoted "
f"expression, got {attr.raw!r}",
loc,
)
value = _lookup_var(attr.raw, "FILTER", variables, loc)
assert isinstance(value.value, FilterExpr)
return EventFilter(name=attr.raw, expr=value.value)
def _parse_attributes(attr_str: str, loc: SourceLoc) -> Dict[str, AttrValue]:
attributes: Dict[str, AttrValue] = {}
pos = 0
for match in _ATTR_RE.finditer(attr_str):
gap = attr_str[pos : match.start()]
if gap.strip():
raise OrionParseError(f"Malformed attribute near {gap.strip()!r}", loc)
key = match.group("key").upper()
qval = match.group("qval")
if qval is not None:
attributes[key] = AttrValue(raw=qval, value=qval, quoted=True)
else:
raw = match.group("val")
attributes[key] = AttrValue(raw=raw, value=_infer_value(raw), quoted=False)
pos = match.end()
trailing = attr_str[pos:]
if trailing.strip():
raise OrionParseError(f"Malformed attribute near {trailing.strip()!r}", loc)
return attributes
def _infer_value(raw: str) -> AttrScalar:
"""Infer bool, int, then float; otherwise keep the raw string."""
upper = raw.upper()
if upper == "TRUE":
return True
if upper == "FALSE":
return False
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
return raw
# ---------------------------------------------------------------------------
# Layer B: applier
# ---------------------------------------------------------------------------
@dataclass
class ApplyReport:
"""Summary of applying an :class:`OrionDocument` to a timeline."""
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)
# Policies for handling unresolved references: skip silently, warn, or raise.
_POLICIES = ("warn", "error", "skip")
# Attributes accepted on a keyword event but intentionally not emitted.
_IGNORED_KEYWORD_ATTRS = {"FILTER"}
# Completion event attribute handling: (required, known-optional) per type.
# FILTER is applied on PERFORATION events; it is accepted on the other
# completion events but ignored with a warning.
_PERF_REQUIRED = ("MDSTART", "MDEND")
_PERF_KNOWN = {
"MDSTART",
"MDEND",
"RADIUS",
"SKIN",
"COMPLETION_NUMBER",
"FILTER",
"COMMENT",
}
_TUBING_REQUIRED = ("MDSTART", "MDEND")
_TUBING_KNOWN = {"MDSTART", "MDEND", "INNER_DIAMETER", "ROUGHNESS", "COMMENT"}
_VALVE_REQUIRED = ("MD", "TYPE")
_VALVE_KNOWN = {"MD", "TYPE", "STATE", "CV", "AREA", "COMMENT"} | {
"AICD_STRENGTH",
"AICD_DENSITY_CALIB_FLUID",
"AICD_VISCOSITY_CALIB_FLUID",
"AICD_VOL_FLOW_EXP",
"AICD_VISC_FUNC_EXP",
}
_STATE_REQUIRED = ("STATE",)
_STATE_KNOWN = {"STATE", "COMMENT"}
_WELLSPEC_KNOWN = {"GROUP", "CROSSFLOW", "REFDEPTH", "PHASE", "COMMENT"}
_WELLSPEC_PHASES = {"OIL", "GAS", "WATER", "LIQUID"}
_COMPLETION_IGNORED = {"FILTER"}
_PERF_IGNORED = _COMPLETION_IGNORED # backwards-compatible alias
# ORIONEVENTS -> Eclipse item-name translations per keyword.
_WCONHIST_FIELD_MAP = {"VFP": "VFP_TABLE"}
_WELTARG_FIELD_MAP = {"VALUE": "NEW_VALUE"}
def _iso_event_date(event_date: Union[datetime.date, datetime.datetime]) -> str:
"""Format an event date for the timeline API, keeping ms time-of-day."""
if isinstance(event_date, datetime.datetime):
if event_date.microsecond:
return event_date.isoformat(timespec="milliseconds")
return event_date.isoformat()
return event_date.isoformat()
def _apply_event_comment(event: OrionEvent, timeline_event: Any) -> None:
"""Copy an optional COMMENT attribute to the created timeline event."""
comment = event.attributes.get("COMMENT")
if comment is None:
return
timeline_event.comment = str(comment.value)
timeline_event.update()
def apply_orion_events_file(
path: Union[str, "os.PathLike[str]"],
timeline: Any,
project: Any,
*,
case: Any = None,
**options: str,
) -> ApplyReport:
"""Parse an ORIONEVENTS file and apply it to ``timeline``."""
document = parse_orion_events_file(path)
return apply_orion_document(document, timeline, project, case=case, **options)
def coalesce_orion_document(document: OrionDocument) -> OrionDocument:
"""Return a copy with same-owner/type/timestamp events merged.
The first event retains its position. Attributes from later matching events
are applied in source order, adding missing values and overriding repeated
values. Owners are matched by well name, group name, or the global SCHEDULE
scope.
"""
result = copy.deepcopy(document)
def merge_events(events: List[OrionEvent]) -> List[OrionEvent]:
merged: List[OrionEvent] = []
by_key: Dict[
Tuple[Union[datetime.date, datetime.datetime], str], OrionEvent
] = {}
for event in events:
key = (event.event_date, event.event_type.upper())
existing = by_key.get(key)
if existing is None:
by_key[key] = event
merged.append(event)
continue
existing.attributes.update(event.attributes)
if "FILTER" in event.attributes:
existing.filter = event.filter
existing.loc = event.loc
return merged
def merge_well_blocks(blocks: List[WellBlock]) -> List[WellBlock]:
merged_blocks: List[WellBlock] = []
by_name: Dict[str, WellBlock] = {}
for block in blocks:
block_events = block.events
existing = by_name.get(block.well_name)
if existing is None:
block.events = []
by_name[block.well_name] = block
merged_blocks.append(block)
by_name[block.well_name].events.extend(block_events)
for block in merged_blocks:
block.events = merge_events(block.events)
return merged_blocks
def merge_group_blocks(blocks: List[GroupBlock]) -> List[GroupBlock]:
merged_blocks: List[GroupBlock] = []
by_name: Dict[str, GroupBlock] = {}
for block in blocks:
block_events = block.events
existing = by_name.get(block.group_name)
if existing is None:
block.events = []
by_name[block.group_name] = block
merged_blocks.append(block)
by_name[block.group_name].events.extend(block_events)
for block in merged_blocks:
block.events = merge_events(block.events)
return merged_blocks
result.wells = merge_well_blocks(result.wells)
result.groups = merge_group_blocks(result.groups)
result.schedule_events = merge_events(result.schedule_events)
return result
def _enum_text(value: Any) -> str:
"""Return the serialized text of a generated enum or plain string."""
return str(getattr(value, "value", value)).upper()
def _prepare_wellspec_events(
events: List[OrionEvent], completion_settings: Any, report: ApplyReport
) -> None:
"""Validate WELLSPEC attributes and resolve partial updates chronologically."""
state = WellSpecState(
group=str(completion_settings.group_name_for_export),
crossflow=bool(completion_settings.allow_well_cross_flow),
refdepth=completion_settings.reference_depth_for_export,
phase=_enum_text(completion_settings.well_type_for_export),
)
wellspecs = sorted(
(event for event in events if event.event_type.upper() == "WELLSPEC"),
key=lambda event: event.event_date,
)
for event in wellspecs:
attrs = event.attributes
unknown = set(attrs) - _WELLSPEC_KNOWN
if unknown:
report.errors.append(
f"Line {event.loc.line}: unknown WELLSPEC attribute(s): "
f"{', '.join(sorted(unknown))}"
)
report.events_skipped += 1
continue
if not (set(attrs) - {"COMMENT"}):
report.errors.append(
f"Line {event.loc.line}: WELLSPEC needs at least one setting attribute"
)
report.events_skipped += 1
continue
next_state = copy.copy(state)
errors: List[str] = []
if "GROUP" in attrs:
value = attrs["GROUP"].value
if not isinstance(value, str) or not value:
errors.append("GROUP must be a non-empty string")
else:
next_state.group = value
if "CROSSFLOW" in attrs:
value = attrs["CROSSFLOW"].value
if not isinstance(value, bool):
errors.append("CROSSFLOW must be True or False")
else:
next_state.crossflow = value
if "REFDEPTH" in attrs:
value = attrs["REFDEPTH"].value
if isinstance(value, bool) or not isinstance(value, (int, float)):
errors.append("REFDEPTH must be numeric")
else:
next_state.refdepth = float(value)
if "PHASE" in attrs:
value = attrs["PHASE"].value
phase = value.upper() if isinstance(value, str) else ""
if phase not in _WELLSPEC_PHASES:
errors.append("PHASE must be OIL, GAS, WATER, or LIQUID")
else:
next_state.phase = phase
if errors:
report.errors.extend(f"Line {event.loc.line}: {error}" for error in errors)
report.events_skipped += 1
continue
state = next_state
event.well_spec = copy.copy(state)
def apply_orion_document(
document: OrionDocument,
timeline: Any,
project: Any,
*,
case: Any = None,
on_unknown_well: str = "warn",
on_unknown_event: str = "warn",
) -> ApplyReport:
"""Apply a parsed :class:`OrionDocument` to a ``WellEventTimeline``.
Arguments:
document: The parsed document (see :func:`parse_orion_events`).
timeline: A ``rips.WellEventTimeline`` object.
project: A ``rips.Project`` used to resolve well names to well paths.
case: The ``rips.Case`` used to resolve filter result names and to own
the combined data filters created from FILTER expressions. Only
needed when the document uses filters; defaults to the project's
first case. Raises :class:`RipsError` if a filter is used and no
case is available, or if a filter references a result that does
not exist in the case (checked up front, before applying events).
on_unknown_well: ``"warn"`` (default), ``"error"`` or ``"skip"`` -
behavior when a well name has no matching well path.
on_unknown_event: same policy values, for event types that closely
resemble a misspelled built-in event type. Other unrecognized
event types are passed through as generic Eclipse keywords.
Returns:
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")
document = coalesce_orion_document(document)
report = ApplyReport()
report.report_dates = sorted({d.isoformat() for d in document.report_dates})
ctx = _prepare_filter_context(document, project, case)
for well in document.wells:
well_path = project.well_path_by_name(well.well_name)
if well_path is None:
message = f"Unknown well '{well.well_name}' (line {well.loc.line})"
if on_unknown_well == "error":
raise RipsError(message)
if on_unknown_well == "warn":
report.warnings.append(message)
report.events_skipped += len(well.events)
continue
_prepare_wellspec_events(well.events, well_path.completion_settings(), report)
for event in well.events:
event_type = event.event_type.upper()
dispatch = _EVENT_DISPATCH.get(event_type)
if dispatch is None:
typo_of = _suspected_typo(event_type)
if typo_of is not None:
message = (
f"Unknown event type '{event.event_type}' "
f"(line {event.loc.line}); did you mean '{typo_of}'?"
)
if on_unknown_event == "error":
raise RipsError(message)
if on_unknown_event == "warn":
report.warnings.append(message)
report.events_skipped += 1
continue
dispatch = _apply_generic_well_keyword
dispatch(event, well_path, timeline, report, ctx)
for group in document.groups:
for event in group.events:
_apply_schedule_event(event, timeline, report, group.group_name)
for event in document.schedule_events:
_apply_schedule_event(event, timeline, report)
return report
# Search order for unqualified filter result names.
_UNQUALIFIED_RESULT_SEARCH_ORDER = ("STATIC_NATIVE", "DYNAMIC_NATIVE", "GENERATED")
@dataclass
class _FilterContext:
"""Per-apply-run state for materializing filter expressions on a case."""
case: Any
resolved_types: Dict[FilterTerm, str] = field(default_factory=dict)
combined_by_key: Dict[str, Any] = field(default_factory=dict)
properties_by_type: Dict[str, List[str]] = field(default_factory=dict)
def available(self, result_type: str) -> List[str]:
if result_type not in self.properties_by_type:
try:
names = list(self.case.available_properties(result_type))
except RipsError:
# The gRPC service reports NOT_FOUND when the case has no
# results of this type at all; treat that as an empty list.
names = []
self.properties_by_type[result_type] = names
return self.properties_by_type[result_type]
def _prepare_filter_context(
document: OrionDocument, project: Any, case: Any
) -> Optional[_FilterContext]:
"""Build a filter context when the document uses filters; else None.
Resolves every filter term's result type up front so a missing result
raises before any event has been applied.
"""
filtered_events: List[Tuple[OrionEvent, EventFilter]] = []
for well in document.wells:
for event in well.events:
if event.filter is not None:
filtered_events.append((event, event.filter))
if not filtered_events:
return None
if case is None:
cases = project.cases()
case = cases[0] if cases else None
if case is None:
raise RipsError(
"The document uses FILTER but no case is available; pass case= "
"or load a case in the project"
)
ctx = _FilterContext(case=case)
missing: List[str] = []
for event, event_filter in filtered_events:
label = event_filter.name or event_filter.expr.raw
for term in event_filter.expr.terms:
if term in ctx.resolved_types:
continue
resolved = _resolve_result_type(ctx, term)
if resolved is not None:
ctx.resolved_types[term] = resolved
continue
if term.result_type is not None:
missing.append(
f"Line {event.loc.line}: filter '{label}': result "
f"'{term.result_name}' not found among "
f"{term.result_type} results"
)
else:
missing.append(
f"Line {event.loc.line}: filter '{label}': result "
f"'{term.result_name}' not found (searched "
f"{', '.join(_UNQUALIFIED_RESULT_SEARCH_ORDER)})"
)
if missing:
raise RipsError("\n".join(missing))
return ctx
def _resolve_result_type(ctx: _FilterContext, term: FilterTerm) -> Optional[str]:
"""Return the result type holding this term's result, or None if missing."""
search_order = (
(term.result_type,)
if term.result_type is not None
else _UNQUALIFIED_RESULT_SEARCH_ORDER
)
for result_type in search_order:
if term.result_name in ctx.available(result_type):
return result_type
return None
def _materialize_filter(ctx: _FilterContext, event_filter: EventFilter) -> Any:
"""Create (or reuse) the case-level combined filter for an event filter.
Declared filters are created once per declaration name and shared between
the perforations that reference them; inline filters are shared when their
expression text is identical.
"""
key = event_filter.name or event_filter.expr.raw
existing = ctx.combined_by_key.get(key)
if existing is not None:
return existing
expr = event_filter.expr
combined = ctx.case.data_filter_collection().add_combined_filter(
name=event_filter.name or "", combine_mode=expr.combine_mode
)
for term in expr.terms:
property_filter = combined.add_property_filter(
result_variable=term.result_name,
result_type=ctx.resolved_types[term],
)
# Only one bound is set; the other keeps the default result min/max,
# so '>' and '>=' are equivalent (bounds are inclusive).
if term.op in (">", ">="):
property_filter.lower_bound = term.value
else:
property_filter.upper_bound = term.value
property_filter.update()
ctx.combined_by_key[key] = combined
return combined
def _suspected_typo(event_type: str) -> Optional[str]:
"""Return the built-in event type this one looks like a misspelling of."""
close = difflib.get_close_matches(event_type, _EVENT_DISPATCH, n=1, cutoff=0.8)
return close[0] if close else None
def _apply_member_event(
event: OrionEvent,
timeline: Any,
report: ApplyReport,
group_name: Optional[str],
) -> None:
"""Expand a GROUP MEMBER event into one GRUPTREE event per member."""
if group_name is None:
report.errors.append(f"Line {event.loc.line}: MEMBER needs a GROUP block")
report.events_skipped += 1
return
unknown = set(event.attributes) - {"MEMBERS", "COMMENT"}
if unknown:
report.errors.append(
f"Line {event.loc.line}: unknown MEMBER attribute(s): "
f"{', '.join(sorted(unknown))}"
)
report.events_skipped += 1
return
if "MEMBERS" not in event.attributes:
report.errors.append(
f"Line {event.loc.line}: MEMBER missing required attribute: MEMBERS"
)
report.events_skipped += 1
return
raw_members = str(event.attributes["MEMBERS"].value)
members = [member.strip() for member in raw_members.split(",")]
if not members or any(not member for member in members):
report.errors.append(
f"Line {event.loc.line}: MEMBERS must be a comma-delimited list of "
"non-empty names"
)
report.events_skipped += 1
return
unique_members = list(dict.fromkeys(members))
for member in unique_members:
timeline_event = timeline.add_keyword_event(
event_date=_iso_event_date(event.event_date),
keyword_name="GRUPTREE",
keyword_data={"CHILD_GROUP": member, "PARENT_GROUP": group_name},
)
_apply_event_comment(event, timeline_event)
report.events_applied += 1
def _apply_schedule_event(
event: OrionEvent,
timeline: Any,
report: ApplyReport,
group_name: Optional[str] = None,
) -> None:
"""Apply one GROUP- or SCHEDULE-block event as an Eclipse keyword."""
event_type = event.event_type.upper()
if event_type == "RESTART":
timeline.add_keyword_event(
event_date=_iso_event_date(event.event_date),
keyword_name="RESTART",
keyword_data={},
)
report.events_applied += 1
return
if event_type == "MEMBER":
_apply_member_event(event, timeline, report, group_name)
return
if event_type in _COMPLETION_EVENT_TYPES:
report.errors.append(
f"Line {event.loc.line}: {event_type} is a completion event and "
"needs a WELL block, not GROUP or SCHEDULE"
)
report.events_skipped += 1
return
keyword_data: Dict[str, Any] = {}
for key, attr in event.attributes.items():
if key == "COMMENT":
continue
if key in _IGNORED_KEYWORD_ATTRS:
report.warnings.append(
f"Line {event.loc.line}: attribute '{key}' on {event_type} "
"is ignored (not yet supported)"
)
continue
keyword_data[key] = attr.value
if group_name is not None:
keyword_data["GROUP"] = group_name
timeline_event = timeline.add_keyword_event(
event_date=_iso_event_date(event.event_date),
keyword_name=event_type,
keyword_data=keyword_data,
)
_apply_event_comment(event, timeline_event)
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}")
def _check_completion_attrs(
event: OrionEvent,
type_name: str,
known: Set[str],
required: Tuple[str, ...],
report: ApplyReport,
ignored: Set[str] = _COMPLETION_IGNORED,
) -> bool:
"""Validate a completion event's attributes; False means skip the event."""
attrs = event.attributes
unknown = set(attrs) - known - ignored
if unknown:
report.errors.append(
f"Line {event.loc.line}: unknown {type_name} attribute(s): "
f"{', '.join(sorted(unknown))}"
)
report.events_skipped += 1
return False
for key in sorted(ignored & set(attrs)):
report.warnings.append(
f"Line {event.loc.line}: attribute '{key}' on {type_name} "
"is ignored (not yet supported)"
)
missing = [name for name in required if name not in attrs]
if missing:
report.errors.append(
f"Line {event.loc.line}: {type_name} missing required "
f"attribute(s): {', '.join(missing)}"
)
report.events_skipped += 1
return False
return True
def _apply_perforation(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
if not _check_completion_attrs(
event, "PERFORATION", _PERF_KNOWN, _PERF_REQUIRED, report, ignored=set()
):
return
attrs = event.attributes
try:
kwargs: Dict[str, Any] = {
"event_date": _iso_event_date(event.event_date),
"well_path": well_path,
"start_md": float(_as_number(attrs["MDSTART"], event.loc)),
"end_md": float(_as_number(attrs["MDEND"], event.loc)),
"state": "OPEN",
}
if "RADIUS" in attrs:
kwargs["diameter"] = 2.0 * float(_as_number(attrs["RADIUS"], event.loc))
if "SKIN" in attrs:
kwargs["skin_factor"] = float(_as_number(attrs["SKIN"], event.loc))
if "COMPLETION_NUMBER" in attrs:
kwargs["completion_number"] = int(
_as_number(attrs["COMPLETION_NUMBER"], event.loc)
)
except OrionParseError as exc:
report.errors.append(str(exc))
report.events_skipped += 1
return
perf_event = timeline.add_perf_event(**kwargs)
if event.filter is not None and ctx is not None:
perf_event.add_filter(filter=_materialize_filter(ctx, event.filter))
_apply_event_comment(event, perf_event)
report.events_applied += 1
def _apply_tubing(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
if not _check_completion_attrs(
event, "TUBING", _TUBING_KNOWN, _TUBING_REQUIRED, report
):
return
attrs = event.attributes
try:
kwargs: Dict[str, Any] = {
"event_date": _iso_event_date(event.event_date),
"well_path": well_path,
"start_md": float(_as_number(attrs["MDSTART"], event.loc)),
"end_md": float(_as_number(attrs["MDEND"], event.loc)),
}
if "INNER_DIAMETER" in attrs:
kwargs["inner_diameter"] = float(
_as_number(attrs["INNER_DIAMETER"], event.loc)
)
if "ROUGHNESS" in attrs:
kwargs["roughness"] = float(_as_number(attrs["ROUGHNESS"], event.loc))
except OrionParseError as exc:
report.errors.append(str(exc))
report.events_skipped += 1
return
timeline_event = timeline.add_tubing_event(**kwargs)
_apply_event_comment(event, timeline_event)
report.events_applied += 1
# VALVE attribute -> add_valve_event keyword-argument name (numeric values).
_VALVE_NUMERIC_KWARGS = {
"CV": "flow_coefficient",
"AREA": "area",
"AICD_STRENGTH": "aicd_strength",
"AICD_DENSITY_CALIB_FLUID": "aicd_density_calib_fluid",
"AICD_VISCOSITY_CALIB_FLUID": "aicd_viscosity_calib_fluid",
"AICD_VOL_FLOW_EXP": "aicd_vol_flow_exp",
"AICD_VISC_FUNC_EXP": "aicd_visc_func_exp",
}
def _apply_valve(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
if not _check_completion_attrs(
event, "VALVE", _VALVE_KNOWN, _VALVE_REQUIRED, report
):
return
attrs = event.attributes
try:
kwargs: Dict[str, Any] = {
"event_date": _iso_event_date(event.event_date),
"well_path": well_path,
"measured_depth": float(_as_number(attrs["MD"], event.loc)),
"valve_type": str(attrs["TYPE"].value),
}
if "STATE" in attrs:
kwargs["state"] = str(attrs["STATE"].value)
for attr_name, kwarg_name in _VALVE_NUMERIC_KWARGS.items():
if attr_name in attrs:
kwargs[kwarg_name] = float(_as_number(attrs[attr_name], event.loc))
except OrionParseError as exc:
report.errors.append(str(exc))
report.events_skipped += 1
return
timeline_event = timeline.add_valve_event(**kwargs)
_apply_event_comment(event, timeline_event)
report.events_applied += 1
def _apply_state(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
if not _check_completion_attrs(
event, "STATE", _STATE_KNOWN, _STATE_REQUIRED, report
):
return
timeline_event = timeline.add_state_event(
event_date=_iso_event_date(event.event_date),
well_path=well_path,
well_state=str(event.attributes["STATE"].value),
)
_apply_event_comment(event, timeline_event)
report.events_applied += 1
def _apply_wellspec(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
if event.well_spec is None:
return
state = event.well_spec
timeline_event = timeline.add_wellspec_event(
event_date=_iso_event_date(event.event_date),
well_path=well_path,
group_name=state.group,
allow_cross_flow=state.crossflow,
reference_depth=state.refdepth,
well_type=state.phase,
)
_apply_event_comment(event, timeline_event)
report.events_applied += 1
def _apply_keyword(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
keyword_name: str,
field_map: Dict[str, str],
) -> None:
keyword_data: Dict[str, Any] = {"WELL": well_path.name}
for key, attr in event.attributes.items():
if key == "COMMENT":
continue
if key in _IGNORED_KEYWORD_ATTRS:
report.warnings.append(
f"Line {event.loc.line}: attribute '{key}' on {keyword_name} "
"is ignored (not yet supported)"
)
continue
keyword_data[field_map.get(key, key)] = attr.value
timeline_event = timeline.add_well_keyword_event(
event_date=_iso_event_date(event.event_date),
well_path=well_path,
keyword_name=keyword_name,
keyword_data=keyword_data,
)
_apply_event_comment(event, timeline_event)
report.events_applied += 1
def _apply_wconhist(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
_apply_keyword(event, well_path, timeline, report, "WCONHIST", _WCONHIST_FIELD_MAP)
def _apply_weltarg(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
_apply_keyword(event, well_path, timeline, report, "WELTARG", _WELTARG_FIELD_MAP)
def _as_number(attr: AttrValue, loc: SourceLoc) -> Union[int, float]:
if isinstance(attr.value, bool) or not isinstance(attr.value, (int, float)):
raise OrionParseError(f"Expected a numeric value, got {attr.raw!r}", loc)
return attr.value
def _apply_generic_well_keyword(
event: OrionEvent,
well_path: Any,
timeline: Any,
report: ApplyReport,
ctx: Optional[_FilterContext] = None,
) -> None:
_apply_keyword(event, well_path, timeline, report, event.event_type.upper(), {})
_EventDispatch = Callable[
[OrionEvent, Any, Any, ApplyReport, Optional[_FilterContext]], None
]
# Built-in event types. Any other event type inside a WELL block is passed
# through as a generic Eclipse well keyword (unless it looks like a typo of a
# built-in, which is governed by the on_unknown_event policy).
_EVENT_DISPATCH: Dict[str, _EventDispatch] = {
"PERFORATION": _apply_perforation,
"TUBING": _apply_tubing,
"VALVE": _apply_valve,
"STATE": _apply_state,
"WELLSPEC": _apply_wellspec,
"WCONHIST": _apply_wconhist,
"WELTARG": _apply_weltarg,
}
# Completion event types that require a well and cannot appear in a SCHEDULE
# block or be emitted as Eclipse keywords.
_COMPLETION_EVENT_TYPES = (
"PERFORATION",
"TUBING",
"VALVE",
"STATE",
"WELLSPEC",
)
# ---------------------------------------------------------------------------
# Standalone validator CLI: python3 -m rips.orion_events <file.orion>
# ---------------------------------------------------------------------------
def _cli(argv: Optional[List[str]] = None) -> int:
import argparse
arg_parser = argparse.ArgumentParser(
prog="python3 -m rips.orion_events",
description="Validate an ORIONEVENTS file (parse only; no ResInsight needed).",
)
arg_parser.add_argument("file", help="path to the ORIONEVENTS file")
args = arg_parser.parse_args(argv)
try:
document = parse_orion_events_file(args.file)
except OSError as exc:
print(f"Error: {exc}")
return 1
except OrionParseError as exc:
for issue in exc.errors:
if issue.loc is not None:
print(f"Line {issue.loc.line}: {issue.message}")
else:
print(issue.message)
print(f"{args.file}: {len(exc.errors)} error(s) found")
return 1
event_count = sum(len(well.events) for well in document.wells)
group_event_count = sum(len(group.events) for group in document.groups)
print(
f"{args.file}: OK (ORIONEVENTS {document.version}, "
f"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)"
)
for warning in document.warnings:
print(f" Warning line {warning.loc.line}: {warning.message}")
return 0
if __name__ == "__main__":
import sys
sys.exit(_cli())