diff --git a/GrpcInterface/Python/rips/orion_events.py b/GrpcInterface/Python/rips/orion_events.py index 8301eb7a9b..10677b18cf 100644 --- a/GrpcInterface/Python/rips/orion_events.py +++ b/GrpcInterface/Python/rips/orion_events.py @@ -55,7 +55,7 @@ File format grammar, version 2.0 (EBNF-ish):: 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 *) + term = signed_integer | dur_ident ; (* whole days, e.g. 2 or -2 *) sign = "+" | "-" ; iso_date = 4digit , "-" , 2digit , "-" , 2digit ; iso_datetime = iso_date , "T" , 2digit , ":" , 2digit , ":" , 2digit , @@ -79,8 +79,10 @@ Notes on the grammar: 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 + ``DURATION`` variable: ``START + RAMP - 2``. An operand may carry its own + sign, so ``START + -2`` is accepted and means the same as ``START - 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 `` opens an event block for a declared ``WELL`` alias; @@ -413,7 +415,10 @@ _KEYWORDS = ( _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{_ISO_DATE}|{_IDENT})" -_TERMS = rf"(?P(?:\s*[-+]\s*(?:\d+|{_IDENT}))*)" +# A term operand may carry its own sign, so "START + -2" subtracts two days. +_SIGNED_INT = r"[-+]?\d+" +_TERMS_BODY = rf"(?:\s*[-+]\s*(?:{_SIGNED_INT}|{_IDENT}))*" +_TERMS = rf"(?P{_TERMS_BODY})" _HEADER_RE = re.compile(r"^ORIONEVENTS\s+(?P\d+\.\d+)$") _UNIT_RE = re.compile(r"^UNIT\s+(?PMETRIC|FIELD|LAB)$") @@ -428,7 +433,7 @@ _INSERT_DATE_RE = re.compile( rf"(?:\s+EVERY\s+(?:(?P\d+)\s+)?" rf"(?PDAY|DAYS|MONTH|MONTHS|YEAR|YEARS)" rf"(?:\s+UNTIL\s+(?P{_ISO_DATE}|{_IDENT})" - rf"(?P(?:\s*[-+]\s*(?:\d+|{_IDENT}))*)" + rf"(?P{_TERMS_BODY})" rf")?)?$" ) _FILTER_DECL_RE = re.compile(rf'^FILTER\s+(?P{_IDENT})\s*=\s*"(?P[^"]*)"$') @@ -449,7 +454,7 @@ _RESULT_TYPE_ALIASES = { _WELL_BLOCK_RE = re.compile(rf'^WELL\s+(?:"(?P[^"]*)"|(?P{_IDENT}))$') _GROUP_BLOCK_RE = re.compile(r'^GROUP\s+"(?P[^"]*)"$') _EVENT_RE = re.compile(rf"^{_DATE_BASE}{_TERMS}\s+(?P.+)$") -_TERM_RE = re.compile(rf"([-+])\s*(\d+|{_IDENT})") +_TERM_RE = re.compile(rf"([-+])\s*({_SIGNED_INT}|{_IDENT})") _ATTR_RE = re.compile(r'(?P[A-Za-z_]\w*)\s*=\s*(?:"(?P[^"]*)"|(?P\S+))') @@ -993,7 +998,9 @@ def _eval_terms(terms: str, variables: Dict[str, OrionValue], loc: SourceLoc) -> total = 0 for match in _TERM_RE.finditer(terms): sign, term = match.groups() - if term.isdigit(): + # An operand may be signed itself ("+ -2"); identifiers never start with + # a sign or a digit, so this stays unambiguous. + if term.lstrip("+-").isdigit(): days = int(term) else: value = _lookup_var(term, "DURATION", variables, loc).value diff --git a/GrpcInterface/Python/rips/tests/test_orion_events.py b/GrpcInterface/Python/rips/tests/test_orion_events.py index 0c1f14a932..8e7b08cab3 100644 --- a/GrpcInterface/Python/rips/tests/test_orion_events.py +++ b/GrpcInterface/Python/rips/tests/test_orion_events.py @@ -325,6 +325,52 @@ class TestParsing: doc = parse_orion_events(text) assert doc.wells[0].events[0].event_date == datetime.date(2017, 12, 27) + def test_signed_operand_in_date_declaration(self): + """An operand may carry its own sign: '+ -2' subtracts (issue #14639).""" + text = "ORIONEVENTS 2.0\nDATE STARTUP = 2026-08-28 + -2\n" + doc = parse_orion_events(text) + assert doc.variables["STARTUP"].value == datetime.date(2026, 8, 26) + + def test_signed_operand_sign_combinations(self): + for expr, expected in ( + ("+ -2", datetime.date(2026, 8, 26)), + ("- -2", datetime.date(2026, 8, 30)), + ("+ +2", datetime.date(2026, 8, 30)), + ("+-2", datetime.date(2026, 8, 26)), + # Unsigned operands keep their previous meaning. + ("+ 2", datetime.date(2026, 8, 30)), + ("- 2", datetime.date(2026, 8, 26)), + ): + doc = parse_orion_events(f"ORIONEVENTS 2.0\nDATE S = 2026-08-28 {expr}\n") + assert doc.variables["S"].value == expected, expr + + def test_signed_operand_on_event_line_and_duration(self): + text = ( + "ORIONEVENTS 2.0\nDATE START = 2018-01-01\nDURATION RAMP = 5 + -2\n" + 'WELL "W"\n START + RAMP + -1 PERFORATION MDSTART=1 MDEND=2\n' + ) + doc = parse_orion_events(text) + assert doc.variables["RAMP"].value == 3 + assert doc.wells[0].events[0].event_date == datetime.date(2018, 1, 3) + + def test_signed_operand_in_insert_date_until(self): + text = ( + "ORIONEVENTS 2.0\nSCHEDULE\n" + "INSERT_DATE 2024-01-01 + -1 EVERY DAY UNTIL 2024-01-05 + -2\n" + ) + doc = parse_orion_events(text) + assert doc.report_dates == [ + datetime.date(2023, 12, 31), + datetime.date(2024, 1, 1), + datetime.date(2024, 1, 2), + datetime.date(2024, 1, 3), + ] + + def test_signed_operand_does_not_accept_fractional_days(self): + """Fractional day offsets remain unsupported.""" + with pytest.raises(OrionParseError, match="Malformed DATE declaration"): + parse_orion_events("ORIONEVENTS 2.0\nDATE S = 2026-08-28 + -2.5\n") + def test_offset_chain_with_duration_variable(self): text = ( "ORIONEVENTS 2.0\nDATE START = 2018-01-01\nDURATION RAMP = 5\n"