Python: Redesign ORIONEVENTS grammar as version 2.0 with typed declarations

Replace the 1.0 grammar with a version 2.0 designed to be easier to parse
and easier to write. Variables are now typed: DATE, DURATION (whole days)
and WELL alias declarations replace the untyped SET, and using a variable
of the wrong type produces an error citing both the use and declaration
site. Well blocks open with an explicit WELL keyword (alias or
double-quoted literal name), quoting is double quotes throughout, and
date arithmetic accepts signed chains of integers and DURATION variables
such as @START + RAMP - 2. ORIONEVENTS 1.x headers are rejected with a
migration message.

FILTER and PERFID are no longer parse errors; any KEY=VALUE attribute
parses and the applier warns and ignores the not-yet-supported keys
(FILTER, PERFID, DSHIFT) while still applying the rest of the event.

The parser now recovers per line and reports every error in one pass via
OrionParseError.errors, unknown variables, event types and keywords get
difflib-based did-you-mean hints, and a stdlib-only validator CLI runs as
python3 -m rips.orion_events <file>. The eager orion_events import in
rips/__init__.py is replaced by a PEP 562 lazy import so the -m entry
point does not trigger runpy's double-import warning.

Update the shipped sample file and example script to the 2.0 syntax and
extend the test suite from 27 to 55 tests covering typed declarations,
offset arithmetic, alias resolution, multi-error diagnostics and the CLI.
This commit is contained in:
Kristian Bendiksen committed 2026-08-07 14:34:35 +02:00
1 parent 1f346d28fd
commit 03236ee737
5 files changed
+716 -169

No files matched your search

@@ -12,7 +12,7 @@ The ORIONEVENTS format is a compact, human-authored description of dated well
events. See rips/orion_events.py for the grammar. A sample input file ships at
rips/example_input_files/well_events.orion.
The well names in the file ('55_33-A-1', ...) must match well paths that exist
The well names in the file ("55_33-A-1", ...) must match well paths that exist
in the open project, so this example assumes a project with matching wells and
an Eclipse case is already loaded.
"""
@@ -37,7 +37,9 @@ def main():
document = rips.orion_events.parse_orion_events_file(orion_file)
print(f" Version: {document.version}, units: {document.unit_system}")
print(f" Wells: {[w.well_name for w in document.wells]}")
print(f" Variables: { {k: str(v) for k, v in document.variables.items()} }")
print(
f" Variables: { {k: f'{v.kind} {v.value}' for k, v in document.variables.items()} }"
)
# Apply the parsed document to the shared well event timeline.
print("\n2. Applying events to the timeline...")
+13 -1
View File
@@ -1,6 +1,7 @@
import logging
import os
import sys
from types import ModuleType
from typing import List
# Configure null handler to prevent "No handler found" warnings
@@ -28,9 +29,20 @@ from .simulation_well import SimulationWell as SimulationWell # noqa: E402
from .exception import RipsError as RipsError # noqa: E402
from .surface import RegularSurface as RegularSurface # noqa: E402
from . import well_events as well_events # noqa: F401, E402
from . import orion_events as orion_events # noqa: F401, E402
from . import category_mapping as category_mapping # noqa: F401, E402
def __getattr__(name: str) -> ModuleType:
# Lazy import (PEP 562) so `python -m rips.orion_events` does not trigger
# runpy's "found in sys.modules after import of package" warning while
# `rips.orion_events` still resolves after a plain `import rips`.
if name == "orion_events":
import importlib
return importlib.import_module(".orion_events", __name__)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__: List[str] = []
for key in class_dict(): # noqa: F405
__all__.append(key)
@@ -1,21 +1,25 @@
# Example ORIONEVENTS well-event-timeline file.
# Example ORIONEVENTS well-event-timeline file (version 2.0).
# Parsed by rips.orion_events; see that module's docstring for the grammar.
# Validate with: python3 -m rips.orion_events well_events.orion
ORIONEVENTS 1.0
ORIONEVENTS 2.0
UNIT METRIC
# Dated variables; the "+ N" arithmetic adds whole days.
SET A1_STARTUP = 2018-01-01
SET A2_STARTUP = 2018-03-01 + 9
# Typed declarations: dates, whole-day durations, and well-name aliases.
DATE A1_STARTUP = 2018-01-01
DATE A2_STARTUP = 2018-03-01 + 9
DURATION RAMP = 5 DAYS
'55_33-A-1'
@A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
@A1_STARTUP PERFORATION MDSTART=1664.28 MDEND=1674.18 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=2
@A1_STARTUP PERFORATION MDSTART=1674.18 MDEND=1689.82 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=3
@A1_STARTUP + 5 WCONHIST STATUS=OPEN CMODE=ORAT VFP=1
@A1_STARTUP + 5 WELTARG CMODE=BHP VALUE=50
WELL A1 = "55_33-A-1"
'55_33-A-2'
@A2_STARTUP PERFORATION MDSTART=1692.79 MDEND=1706 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
WELL A1
@A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
@A1_STARTUP PERFORATION MDSTART=1664.28 MDEND=1674.18 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=2
@A1_STARTUP PERFORATION MDSTART=1674.18 MDEND=1689.82 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=3
@A1_STARTUP + RAMP WCONHIST STATUS=OPEN CMODE=ORAT VFP=1
@A1_STARTUP + RAMP WELTARG CMODE=BHP VALUE=50
WELL "55_33-A-2"
@A2_STARTUP - 1 PERFORATION MDSTART=1692.79 MDEND=1706 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
@A2_STARTUP WCONHIST STATUS=OPEN CMODE=ORAT VFP=2
@A2_STARTUP WELTARG CMODE=BHP VALUE=50
+415 -97
View File
@@ -11,41 +11,72 @@ 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.
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 (EBNF-ish)::
File format grammar, version 2.0 (EBNF-ish)::
document = header , [ unit_directive ] , { set_def } , { well_block } ;
header = "ORIONEVENTS" , version ; (* e.g. ORIONEVENTS 1.0 *)
unit_directive= "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ;
set_def = "SET" , ident , "=" , date_expr ; (* SET A1 = 2018-01-01 + 9 *)
well_block = quoted_name , { event_line } ; (* '55_33-A-1' *)
event_line = "@" , date_expr , event_type , { attribute } ;
date_expr = ( iso_date | ident ) , [ "+" , integer ] ; (* offset in days *)
attribute = ident , "=" , ( quoted_string | bareword ) ;
comment = "#" , { any_char } ; (* line or trailing *)
document = header , { statement } ;
header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *)
statement = unit_directive | declaration | well_block_open | event_line ;
unit_directive = "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ;
declaration = date_decl | duration_decl | well_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" *)
well_block_open = "WELL" , ( quoted_string | ident ) ; (* no "=" present *)
event_line = "@" , date_expr , event_type , { attribute } ;
date_expr = ( iso_date | 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 ;
ident = letter_or_underscore , { word_char } ;
attribute = ident , "=" , ( quoted_string | bareword ) ;
comment = "#" , rest-of-line ; (* line or trailing *)
Notes on the grammar:
* Comments start with ``#`` (outside of quotes) and run to end of line.
* Every attribute is ``KEY=VALUE``; bare positional tokens are rejected.
* Date arithmetic is restricted to ``+ <non-negative int>`` (whole days).
* Attribute values may be double-quoted to contain spaces/operators, e.g.
* The format is line-oriented; every non-blank line is dispatched on its first
token: ``ORIONEVENTS`` (once), ``UNIT``, ``DATE``, ``DURATION``, ``WELL`` 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) and ``WELL``
(well-name alias) 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.
* ``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. Empty
well blocks are legal.
* Double quotes are used everywhere: well names and attribute values, e.g.
``FILTER="SOIL(0) > 0.8 AND PERMX > 200"``.
* ``FILTER`` and ``PERFID`` are reserved but **not yet supported**: encountering
them raises :class:`OrionParseError` with the source line. Remove them from the
file until support is added.
* ``DSHIFT`` on keyword events is accepted but ignored (with a warning); it does
not shift the event date.
* Every attribute is ``KEY=VALUE``; bare positional tokens are rejected. Any
key parses; keys the applier does not support yet (``FILTER``, ``PERFID``,
``DSHIFT``) are ignored with a warning when the document is 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 datetime
import difflib
import os
import re
from dataclasses import dataclass, field
@@ -69,15 +100,37 @@ class SourceLoc:
text: str
class OrionParseError(Exception):
"""Raised for structural or unsupported-syntax errors while parsing."""
@dataclass(frozen=True)
class ParseIssue:
"""A single fatal problem found while parsing."""
def __init__(self, message: str, loc: Optional[SourceLoc] = None) -> None:
self.loc = loc
if loc is not None:
super().__init__(f"Line {loc.line}: {message}")
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:
super().__init__(message)
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)
@@ -88,6 +141,15 @@ class ParseWarning:
loc: SourceLoc
@dataclass(frozen=True)
class OrionValue:
"""A typed variable: kind is ``DATE``, ``DURATION`` or ``WELL``."""
kind: str
value: Union[datetime.date, int, str]
loc: SourceLoc
@dataclass(frozen=True)
class AttrValue:
"""A single ``KEY=VALUE`` attribute with its type-inferred value."""
@@ -109,7 +171,7 @@ class OrionEvent:
@dataclass
class WellBlock:
"""A quoted well name followed by its events."""
"""A ``WELL`` block header followed by its events."""
well_name: str
events: List[OrionEvent] = field(default_factory=list)
@@ -122,7 +184,7 @@ class OrionDocument:
version: str
unit_system: str = "METRIC"
variables: Dict[str, datetime.date] = field(default_factory=dict)
variables: Dict[str, OrionValue] = field(default_factory=dict)
wells: List[WellBlock] = field(default_factory=list)
warnings: List[ParseWarning] = field(default_factory=list)
@@ -131,17 +193,23 @@ class OrionDocument:
# Layer A: pure parser
# ---------------------------------------------------------------------------
# Reserved attributes that are recognized but not yet supported.
_UNSUPPORTED_ATTRIBUTES = {"FILTER", "PERFID"}
_KEYWORDS = ("ORIONEVENTS", "UNIT", "DATE", "DURATION", "WELL")
_BASE = r"(?P<base>\d{4}-\d{2}-\d{2}|[A-Za-z_]\w*)"
_OFFSET = r"(?:\+\s*(?P<offset>\d+))?"
_IDENT = r"[A-Za-z_]\w*"
_DATE_BASE = rf"(?P<base>\d{{4}}-\d{{2}}-\d{{2}}|{_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)$")
_WELL_RE = re.compile(r"^'(?P<name>[^']*)'$")
_SET_RE = re.compile(rf"^SET\s+(?P<name>[A-Za-z_]\w*)\s*=\s*{_BASE}\s*{_OFFSET}$")
_EVENT_RE = re.compile(rf"^@\s*{_BASE}\s*{_OFFSET}\s+(?P<rest>.+)$")
_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>[^"]*)"$')
_WELL_BLOCK_RE = re.compile(rf'^WELL\s+(?:"(?P<qname>[^"]*)"|(?P<ref>{_IDENT}))$')
_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+))')
@@ -152,16 +220,18 @@ def parse_orion_events_file(path: Union[str, "os.PathLike[str]"]) -> OrionDocume
def parse_orion_events(text: str) -> OrionDocument:
"""Parse ORIONEVENTS text into an :class:`OrionDocument`.
"""Parse ORIONEVENTS 2.0 text into an :class:`OrionDocument`.
Raises:
OrionParseError: on structural errors or unsupported syntax.
OrionParseError: carrying every error found in the file. A missing or
unsupported ``ORIONEVENTS`` header aborts immediately.
"""
version: Optional[str] = None
unit_system = "METRIC"
variables: Dict[str, datetime.date] = {}
unit_holder = ["METRIC"] # mutable so _parse_line can update it
variables: Dict[str, OrionValue] = {}
wells: List[WellBlock] = []
warnings: List[ParseWarning] = []
errors: List[ParseIssue] = []
current_well: Optional[WellBlock] = None
for lineno, raw_line in enumerate(text.splitlines(), start=1):
@@ -170,7 +240,8 @@ def parse_orion_events(text: str) -> OrionDocument:
if not line:
continue
# The first meaningful line must be the header.
# 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:
@@ -178,49 +249,258 @@ def parse_orion_events(text: str) -> OrionDocument:
"File must start with 'ORIONEVENTS <version>'", loc
)
version = match.group("version")
_check_version(version, loc)
continue
if line.startswith("@"):
if current_well is None:
raise OrionParseError("Event line found before any well block", loc)
current_well.events.append(_parse_event_line(line, variables, loc))
continue
set_match = _SET_RE.match(line)
if set_match is not None:
name = set_match.group("name")
if name in variables:
warnings.append(ParseWarning(f"Duplicate SET '{name}'", loc))
variables[name] = _resolve_date(
set_match.group("base"), set_match.group("offset"), variables, loc
try:
current_well = _parse_line(
line, loc, variables, wells, warnings, current_well, unit_holder
)
continue
unit_match = _UNIT_RE.match(line)
if unit_match is not None:
unit_system = unit_match.group("unit")
continue
well_match = _WELL_RE.match(line)
if well_match is not None:
current_well = WellBlock(well_name=well_match.group("name"), loc=loc)
wells.append(current_well)
continue
raise OrionParseError(f"Unrecognized line: {line!r}", loc)
except OrionParseError as exc:
errors.extend(exc.errors)
if line.split(None, 1)[0] == "WELL" or (
line.startswith("@") and current_well is None
):
# Suppress cascading errors from lines belonging to a broken
# (or missing) block: swallow them into a discarded block.
current_well = WellBlock(well_name="", loc=loc)
if version is None:
raise OrionParseError("Empty file: missing 'ORIONEVENTS' header")
if errors:
raise OrionParseError(errors=errors)
return OrionDocument(
version=version,
unit_system=unit_system,
unit_system=unit_holder[0],
variables=variables,
wells=wells,
warnings=warnings,
)
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],
warnings: List[ParseWarning],
current_well: Optional[WellBlock],
unit_holder: List[str],
) -> Optional[WellBlock]:
"""Dispatch one non-header line; returns the (possibly new) current well."""
if line.startswith("@"):
if current_well is None:
raise OrionParseError("Event line found before any WELL block", loc)
current_well.events.append(_parse_event_line(line, variables, loc))
return current_well
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_well
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_well
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_well
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_well
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
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
) -> datetime.date:
"""Evaluate an ISO date or DATE variable plus optional signed day terms."""
if base[0].isdigit():
try:
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 _strip_comment(line: str) -> str:
"""Remove a ``#`` comment, honoring double-quoted spans."""
in_quote = False
@@ -234,36 +514,15 @@ def _strip_comment(line: str) -> str:
return "".join(result)
def _resolve_date(
base: str,
offset: Optional[str],
variables: Dict[str, datetime.date],
loc: SourceLoc,
) -> datetime.date:
"""Resolve a date expression (ISO date or variable) plus optional day offset."""
if base[0].isalpha() or base[0] == "_":
if base not in variables:
raise OrionParseError(f"Unknown date variable '{base}'", loc)
result = variables[base]
else:
try:
result = datetime.date.fromisoformat(base)
except ValueError as exc:
raise OrionParseError(f"Invalid date '{base}': {exc}", loc)
if offset is not None:
result = result + datetime.timedelta(days=int(offset))
return result
def _parse_event_line(
line: str, variables: Dict[str, datetime.date], loc: SourceLoc
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 = _resolve_date(
match.group("base"), match.group("offset"), variables, loc
event_date = _eval_date_expr(
match.group("base"), match.group("terms"), variables, loc
)
rest = match.group("rest").strip()
parts = rest.split(None, 1)
@@ -286,8 +545,6 @@ def _parse_attributes(attr_str: str, loc: SourceLoc) -> Dict[str, AttrValue]:
if gap.strip():
raise OrionParseError(f"Malformed attribute near {gap.strip()!r}", loc)
key = match.group("key").upper()
if key in _UNSUPPORTED_ATTRIBUTES:
raise OrionParseError(f"Attribute '{key}' is not yet supported", loc)
qval = match.group("qval")
if qval is not None:
@@ -335,11 +592,12 @@ class ApplyReport:
_POLICIES = ("warn", "error", "skip")
# Attributes accepted on a keyword event but intentionally not emitted.
_IGNORED_KEYWORD_ATTRS = {"DSHIFT"}
_IGNORED_KEYWORD_ATTRS = {"DSHIFT", "FILTER", "PERFID"}
# PERFORATION attribute handling.
_PERF_REQUIRED = ("MDSTART", "MDEND")
_PERF_KNOWN = {"MDSTART", "MDEND", "RADIUS", "SKIN", "COMPLETION_NUMBER"}
_PERF_IGNORED = {"FILTER", "PERFID"}
# ORIONEVENTS -> Eclipse item-name translations per keyword.
_WCONHIST_FIELD_MAP = {"VFP": "VFP_TABLE"}
@@ -399,6 +657,11 @@ def apply_orion_document(
message = (
f"Unknown event type '{event.event_type}' (line {event.loc.line})"
)
close = difflib.get_close_matches(
event.event_type.upper(), _EVENT_DISPATCH, n=1, cutoff=0.6
)
if close:
message += f"; did you mean '{close[0]}'?"
if on_unknown_event == "error":
raise RipsError(message)
if on_unknown_event == "warn":
@@ -419,7 +682,7 @@ def _apply_perforation(
event: OrionEvent, well_path: Any, timeline: Any, report: ApplyReport
) -> None:
attrs = event.attributes
unknown = set(attrs) - _PERF_KNOWN
unknown = set(attrs) - _PERF_KNOWN - _PERF_IGNORED
if unknown:
report.errors.append(
f"Line {event.loc.line}: unknown PERFORATION attribute(s): "
@@ -428,6 +691,12 @@ def _apply_perforation(
report.events_skipped += 1
return
for key in sorted(_PERF_IGNORED & set(attrs)):
report.warnings.append(
f"Line {event.loc.line}: attribute '{key}' on PERFORATION "
"is ignored (not yet supported)"
)
missing = [name for name in _PERF_REQUIRED if name not in attrs]
if missing:
report.errors.append(
@@ -514,3 +783,52 @@ _EVENT_DISPATCH: Dict[str, _EventDispatch] = {
"WCONHIST": _apply_wconhist,
"WELTARG": _apply_weltarg,
}
# ---------------------------------------------------------------------------
# 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)
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} event(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())
@@ -23,25 +23,29 @@ import dataroot # noqa: E402
from rips.orion_events import ( # noqa: E402
ApplyReport,
OrionParseError,
_cli,
apply_orion_document,
parse_orion_events,
)
SAMPLE = """\
# A comment line
ORIONEVENTS 1.0
ORIONEVENTS 2.0
UNIT METRIC
SET A1_STARTUP = 2018-01-01
SET A2_STARTUP = 2018-03-01 + 9
DATE A1_STARTUP = 2018-01-01
DATE A2_STARTUP = 2018-03-01 + 9
DURATION RAMP = 5 DAYS
'55_33-A-1'
@A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
@A1_STARTUP + 5 WCONHIST STATUS=OPEN CMODE=ORAT VFP=1
@A1_STARTUP + 5 WELTARG CMODE=BHP VALUE=50
WELL A1 = "55_33-A-1"
'55_33-A-2'
@A2_STARTUP PERFORATION MDSTART=1692.79 MDEND=1706 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
WELL A1
@A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
@A1_STARTUP + RAMP WCONHIST STATUS=OPEN CMODE=ORAT VFP=1
@A1_STARTUP + RAMP WELTARG CMODE=BHP VALUE=50
WELL "55_33-A-2"
@A2_STARTUP PERFORATION MDSTART=1692.79 MDEND=1706 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1
"""
@@ -85,14 +89,22 @@ class FakeTimeline:
class TestParsing:
def test_header_and_unit(self):
doc = parse_orion_events(SAMPLE)
assert doc.version == "1.0"
assert doc.version == "2.0"
assert doc.unit_system == "METRIC"
def test_set_variables_with_day_offset(self):
def test_date_declarations_with_day_offset(self):
doc = parse_orion_events(SAMPLE)
assert doc.variables["A1_STARTUP"] == datetime.date(2018, 1, 1)
assert doc.variables["A1_STARTUP"].kind == "DATE"
assert doc.variables["A1_STARTUP"].value == datetime.date(2018, 1, 1)
# 2018-03-01 + 9 days
assert doc.variables["A2_STARTUP"] == datetime.date(2018, 3, 10)
assert doc.variables["A2_STARTUP"].value == datetime.date(2018, 3, 10)
def test_duration_and_well_declarations(self):
doc = parse_orion_events(SAMPLE)
assert doc.variables["RAMP"].kind == "DURATION"
assert doc.variables["RAMP"].value == 5
assert doc.variables["A1"].kind == "WELL"
assert doc.variables["A1"].value == "55_33-A-1"
def test_well_blocks_and_event_dates(self):
doc = parse_orion_events(SAMPLE)
@@ -103,7 +115,7 @@ class TestParsing:
"WCONHIST",
"WELTARG",
]
# @A1_STARTUP + 5 -> 2018-01-06
# @A1_STARTUP + RAMP -> 2018-01-06
assert well1.events[1].event_date == datetime.date(2018, 1, 6)
def test_value_type_inference(self):
@@ -116,21 +128,24 @@ class TestParsing:
assert wconhist.attributes["STATUS"].value == "OPEN" # string
def test_quoted_filter_value_is_single_attribute(self):
# FILTER itself is unsupported, but the quote-aware tokenizer must treat
# the whole quoted expression as one attribute. Use an allowed key here.
text = (
"ORIONEVENTS 1.0\n'W'\n"
' @2018-01-01 WCONHIST NOTE="SOIL(0) > 0.8 AND PERMX > 200" CMODE=ORAT\n'
'ORIONEVENTS 2.0\nWELL "W"\n'
' @2018-01-01 PERFORATION MDSTART=1 MDEND=2 FILTER="SOIL(0) > 0.8 AND PERMX > 200"\n'
)
doc = parse_orion_events(text)
note = doc.wells[0].events[0].attributes["NOTE"]
assert note.value == "SOIL(0) > 0.8 AND PERMX > 200"
assert note.quoted is True
assert doc.wells[0].events[0].attributes["CMODE"].value == "ORAT"
filter_attr = doc.wells[0].events[0].attributes["FILTER"]
assert filter_attr.value == "SOIL(0) > 0.8 AND PERMX > 200"
assert filter_attr.quoted is True
assert doc.wells[0].events[0].attributes["MDEND"].value == 2
def test_perfid_attribute_parses(self):
text = 'ORIONEVENTS 2.0\nWELL "W"\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2 PERFID=Valysar\n'
doc = parse_orion_events(text)
assert doc.wells[0].events[0].attributes["PERFID"].value == "Valysar"
def test_trailing_comment_ignored_but_not_inside_quotes(self):
text = (
"ORIONEVENTS 1.0\n'W'\n"
'ORIONEVENTS 2.0\nWELL "W"\n'
' @2018-01-01 WCONHIST NOTE="a # b" CMODE=ORAT # trailing comment\n'
)
doc = parse_orion_events(text)
@@ -139,51 +154,212 @@ class TestParsing:
assert "CMODE" in attrs
def test_iso_date_literal_event(self):
text = "ORIONEVENTS 1.0\n'W'\n @2020-12-31 PERFORATION MDSTART=1 MDEND=2\n"
text = (
'ORIONEVENTS 2.0\nWELL "W"\n @2020-12-31 PERFORATION MDSTART=1 MDEND=2\n'
)
doc = parse_orion_events(text)
assert doc.wells[0].events[0].event_date == datetime.date(2020, 12, 31)
def test_negative_offset(self):
text = (
"ORIONEVENTS 2.0\nDATE START = 2018-01-10\n"
'WELL "W"\n @START - 5 PERFORATION MDSTART=1 MDEND=2\n'
)
doc = parse_orion_events(text)
assert doc.wells[0].events[0].event_date == datetime.date(2018, 1, 5)
def test_minus_after_iso_date(self):
text = 'ORIONEVENTS 2.0\nWELL "W"\n @2018-01-01 - 5 PERFORATION MDSTART=1 MDEND=2\n'
doc = parse_orion_events(text)
assert doc.wells[0].events[0].event_date == datetime.date(2017, 12, 27)
def test_offset_chain_with_duration_variable(self):
text = (
"ORIONEVENTS 2.0\nDATE START = 2018-01-01\nDURATION RAMP = 5\n"
'WELL "W"\n @START + RAMP - 2 PERFORATION MDSTART=1 MDEND=2\n'
)
doc = parse_orion_events(text)
assert doc.wells[0].events[0].event_date == datetime.date(2018, 1, 4)
def test_duration_declaration_days_suffix_optional(self):
for suffix in ("", " DAYS", " days"):
doc = parse_orion_events(f"ORIONEVENTS 2.0\nDURATION X = 5{suffix}\n")
assert doc.variables["X"].value == 5
def test_duration_arithmetic_in_declaration(self):
text = "ORIONEVENTS 2.0\nDURATION RAMP = 5\nDURATION X = RAMP + 2 DAYS\n"
doc = parse_orion_events(text)
assert doc.variables["X"].value == 7
def test_well_alias_resolution(self):
doc = parse_orion_events(SAMPLE)
# 'WELL A1' block resolves to the declared alias target.
assert doc.wells[0].well_name == "55_33-A-1"
def test_quoted_well_block_ignores_alias(self):
text = 'ORIONEVENTS 2.0\nWELL A1 = "55_33-A-1"\nWELL "A1"\n'
doc = parse_orion_events(text)
assert doc.wells[0].well_name == "A1"
def test_unknown_well_alias_raises(self):
with pytest.raises(OrionParseError, match="Unknown variable 'NOPE'"):
parse_orion_events("ORIONEVENTS 2.0\nWELL NOPE\n")
def test_empty_well_block_ok(self):
doc = parse_orion_events('ORIONEVENTS 2.0\nWELL "W"\n')
assert doc.wells[0].well_name == "W"
assert doc.wells[0].events == []
def test_v1_file_rejected_with_clear_message(self):
with pytest.raises(OrionParseError, match="no longer supported"):
parse_orion_events("ORIONEVENTS 1.0\nUNIT METRIC\n")
def test_unsupported_version_rejected(self):
with pytest.raises(OrionParseError, match="Unsupported ORIONEVENTS version"):
parse_orion_events("ORIONEVENTS 3.0\n")
def test_missing_header_raises(self):
with pytest.raises(OrionParseError):
parse_orion_events("UNIT METRIC\n'W'\n")
parse_orion_events('UNIT METRIC\nWELL "W"\n')
def test_event_before_well_block_raises(self):
with pytest.raises(OrionParseError):
with pytest.raises(OrionParseError, match="before any WELL block"):
parse_orion_events(
"ORIONEVENTS 1.0\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n"
"ORIONEVENTS 2.0\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n"
)
def test_unknown_variable_raises(self):
with pytest.raises(OrionParseError):
with pytest.raises(OrionParseError, match="Unknown variable 'NOPE'"):
parse_orion_events(
"ORIONEVENTS 1.0\n'W'\n @NOPE PERFORATION MDSTART=1 MDEND=2\n"
'ORIONEVENTS 2.0\nWELL "W"\n @NOPE PERFORATION MDSTART=1 MDEND=2\n'
)
def test_filter_attribute_is_unsupported(self):
def test_duration_where_date_expected_raises(self):
text = (
"ORIONEVENTS 1.0\n'W'\n"
' @2018-01-01 PERFORATION MDSTART=1 MDEND=2 FILTER="SOIL(0) > 0.8"\n'
"ORIONEVENTS 2.0\nDURATION RAMP = 5\n"
'WELL "W"\n @RAMP WCONHIST STATUS=OPEN\n'
)
with pytest.raises(OrionParseError, match="FILTER"):
with pytest.raises(
OrionParseError, match=r"is a DURATION \(declared line 2\) but a DATE"
):
parse_orion_events(text)
def test_perfid_attribute_is_unsupported(self):
text = "ORIONEVENTS 1.0\n'W'\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2 PERFID=Valysar\n"
with pytest.raises(OrionParseError, match="PERFID"):
def test_date_where_duration_expected_raises(self):
text = (
"ORIONEVENTS 2.0\nDATE START = 2018-01-01\nDATE OTHER = 2018-02-01\n"
'WELL "W"\n @START + OTHER WCONHIST STATUS=OPEN\n'
)
with pytest.raises(OrionParseError, match="is a DATE .* but a DURATION"):
parse_orion_events(text)
def test_well_variable_in_date_context_raises(self):
text = 'ORIONEVENTS 2.0\nWELL A1 = "X"\nWELL A1\n @A1 WCONHIST STATUS=OPEN\n'
with pytest.raises(OrionParseError, match="is a WELL .* but a DATE"):
parse_orion_events(text)
def test_cross_type_redefinition_raises(self):
text = "ORIONEVENTS 2.0\nDATE X = 2018-01-01\nDURATION X = 5\n"
with pytest.raises(
OrionParseError, match="already declared as DATE .* redeclare as DURATION"
):
parse_orion_events(text)
def test_forward_reference_raises(self):
text = (
'ORIONEVENTS 2.0\nWELL "W"\n @START PERFORATION MDSTART=1 MDEND=2\n'
"DATE START = 2018-01-01\n"
)
with pytest.raises(OrionParseError, match="Unknown variable 'START'"):
parse_orion_events(text)
def test_single_quoted_well_name_rejected(self):
text = "ORIONEVENTS 2.0\n'W'\n"
with pytest.raises(OrionParseError, match="1.x syntax"):
parse_orion_events(text)
def test_set_line_rejected_with_hint(self):
text = "ORIONEVENTS 2.0\nSET X = 2018-01-01\n"
with pytest.raises(OrionParseError, match="SET is ORIONEVENTS 1.x syntax"):
parse_orion_events(text)
def test_malformed_attribute_raises(self):
text = (
"ORIONEVENTS 1.0\n'W'\n @2018-01-01 PERFORATION MDSTART=1 bogus MDEND=2\n"
)
with pytest.raises(OrionParseError):
text = 'ORIONEVENTS 2.0\nWELL "W"\n @2018-01-01 PERFORATION MDSTART=1 bogus MDEND=2\n'
with pytest.raises(OrionParseError, match="Malformed attribute"):
parse_orion_events(text)
def test_duplicate_set_warns(self):
text = "ORIONEVENTS 1.0\nSET X = 2018-01-01\nSET X = 2019-01-01\n'W'\n"
def test_duplicate_date_declaration_warns(self):
text = 'ORIONEVENTS 2.0\nDATE X = 2018-01-01\nDATE X = 2019-01-01\nWELL "W"\n'
doc = parse_orion_events(text)
assert any("Duplicate" in w.message for w in doc.warnings)
assert doc.variables["X"] == datetime.date(2019, 1, 1)
assert doc.variables["X"].value == datetime.date(2019, 1, 1)
class TestDiagnostics:
def test_all_errors_reported_in_one_pass(self):
text = (
"ORIONEVENTS 2.0\n"
"DATE A1_STARTUP = 2018-01-01\n"
"SET X = 2018-01-01\n" # line 3
'WELL "W"\n'
" @A1_STRTUP PERFORATION MDSTART=1 MDEND=2\n" # line 5
" @A1_STARTUP + NOPE WCONHIST STATUS=OPEN\n" # line 6
)
with pytest.raises(OrionParseError) as excinfo:
parse_orion_events(text)
assert [issue.loc.line for issue in excinfo.value.errors] == [3, 5, 6]
def test_malformed_well_block_suppresses_cascading_errors(self):
text = (
"ORIONEVENTS 2.0\n"
"WELL 55_33-A-2\n" # malformed: unquoted special characters
" @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n"
" @2018-01-01 WCONHIST STATUS=OPEN\n"
)
with pytest.raises(OrionParseError) as excinfo:
parse_orion_events(text)
assert len(excinfo.value.errors) == 1
assert "Malformed WELL line" in excinfo.value.errors[0].message
def test_unknown_variable_hint(self):
text = (
"ORIONEVENTS 2.0\nDATE A1_STARTUP = 2018-01-01\n"
'WELL "W"\n @A1_STRTUP PERFORATION MDSTART=1 MDEND=2\n'
)
with pytest.raises(OrionParseError, match="did you mean 'A1_STARTUP'"):
parse_orion_events(text)
def test_misspelled_keyword_hint(self):
with pytest.raises(OrionParseError, match="did you mean 'DURATION'"):
parse_orion_events("ORIONEVENTS 2.0\nDURATON X = 5\n")
class TestValidatorCli:
def _write(self, tmp_path, text):
path = tmp_path / "events.orion"
path.write_text(text)
return str(path)
def test_valid_file_exits_zero_with_summary(self, tmp_path, capsys):
path = self._write(tmp_path, SAMPLE)
assert _cli([path]) == 0
out = capsys.readouterr().out
assert "OK" in out
assert "4 variable(s), 2 well block(s), 4 event(s)" in out
def test_invalid_file_exits_nonzero_with_errors(self, tmp_path, capsys):
path = self._write(
tmp_path,
'ORIONEVENTS 2.0\nSET X = 2018-01-01\nWELL "W"\n @NOPE WCONHIST A=1\n',
)
assert _cli([path]) == 1
out = capsys.readouterr().out
assert "Line 2" in out
assert "Line 4" in out
assert "2 error(s) found" in out
def test_missing_file_exits_nonzero(self, tmp_path, capsys):
assert _cli([str(tmp_path / "nope.orion")]) == 1
assert "Error" in capsys.readouterr().out
# ---------------------------------------------------------------------------
@@ -234,7 +410,7 @@ class TestApplying:
def test_dshift_is_ignored_with_warning(self):
text = (
"ORIONEVENTS 1.0\n'55_33-A-1'\n"
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
" @2018-01-01 WCONHIST STATUS=OPEN CMODE=ORAT DSHIFT=10\n"
)
timeline, report = self._apply(text)
@@ -244,6 +420,36 @@ class TestApplying:
assert timeline.keyword_calls[0]["event_date"] == "2018-01-01"
assert any("DSHIFT" in w for w in report.warnings)
def test_filter_on_perforation_warns_and_applies(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
' @2018-01-01 PERFORATION MDSTART=1 MDEND=2 FILTER="SOIL(0) > 0.8"\n'
)
timeline, report = self._apply(text)
assert report.events_applied == 1
assert report.events_skipped == 0
assert any("FILTER" in w for w in report.warnings)
assert len(timeline.perf_calls) == 1
def test_perfid_on_perforation_warns_and_applies(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
" @2018-01-01 PERFORATION MDSTART=1 MDEND=2 PERFID=Valysar\n"
)
timeline, report = self._apply(text)
assert report.events_applied == 1
assert any("PERFID" in w for w in report.warnings)
def test_filter_on_keyword_event_warns_and_applies(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
' @2018-01-01 WCONHIST STATUS=OPEN FILTER="PERMX > 200"\n'
)
timeline, report = self._apply(text)
assert report.events_applied == 1
assert "FILTER" not in timeline.keyword_calls[0]["keyword_data"]
assert any("FILTER" in w for w in report.warnings)
def test_unknown_well_warns_and_skips(self):
timeline, report = self._apply(SAMPLE, names=("55_33-A-1",))
# Second well is unknown -> its single perforation is skipped.
@@ -256,20 +462,24 @@ class TestApplying:
with pytest.raises(RipsError):
self._apply(SAMPLE, names=("55_33-A-1",), on_unknown_well="error")
def test_unknown_event_type_warns(self):
text = "ORIONEVENTS 1.0\n'55_33-A-1'\n @2018-01-01 FOObar X=1\n"
def test_unknown_event_type_warns_with_hint(self):
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 WCONHST STATUS=OPEN\n'
_, report = self._apply(text)
assert report.events_skipped == 1
assert any("FOObar" in w for w in report.warnings)
assert any(
"WCONHST" in w and "did you mean 'WCONHIST'" in w for w in report.warnings
)
def test_perforation_missing_required_attr_is_error(self):
text = "ORIONEVENTS 1.0\n'55_33-A-1'\n @2018-01-01 PERFORATION MDSTART=1\n"
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 PERFORATION MDSTART=1\n'
)
_, report = self._apply(text)
assert report.events_skipped == 1
assert any("MDEND" in e for e in report.errors)
def test_perforation_unknown_attr_is_error(self):
text = "ORIONEVENTS 1.0\n'55_33-A-1'\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2 ZZZ=3\n"
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2 ZZZ=3\n'
_, report = self._apply(text)
assert report.events_skipped == 1
assert any("ZZZ" in e for e in report.errors)
@@ -316,13 +526,14 @@ class TestOrionEventsIntegration:
# Reference the real well name; MD range is valid for well path A.
text = (
"ORIONEVENTS 1.0\n"
"ORIONEVENTS 2.0\n"
"UNIT METRIC\n"
"SET START = 2024-01-01\n"
f"'{well.name}'\n"
" @START PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1\n"
" @START + 5 WCONHIST STATUS=OPEN CMODE=ORAT VFP=1\n"
" @START + 5 WELTARG CMODE=BHP VALUE=50\n"
"DATE START = 2024-01-01\n"
"DURATION RAMP = 5 DAYS\n"
f'WELL "{well.name}"\n'
" @START PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1\n"
" @START + RAMP WCONHIST STATUS=OPEN CMODE=ORAT VFP=1\n"
" @START + RAMP WELTARG CMODE=BHP VALUE=50\n"
)
document = parse_orion_events(text)
report = apply_orion_document(document, timeline, project)
@@ -351,7 +562,7 @@ class TestOrionEventsIntegration:
):
project, _case, timeline = project_with_case_and_wells
text = (
"ORIONEVENTS 1.0\n'NO_SUCH_WELL'\n"
'ORIONEVENTS 2.0\nWELL "NO_SUCH_WELL"\n'
" @2024-01-01 PERFORATION MDSTART=1 MDEND=2\n"
)
document = parse_orion_events(text)