#14639 Orion Events: Allow signed operand in date arithmetic

A term operand had to be an unsigned integer or a DURATION variable, so
"DATE STARTUP = 2026-08-28 + -2" was rejected as a malformed declaration
instead of subtracting two days.

Accept an optional sign on the operand in date and duration expressions, and
share one pattern between the terms of a date expression and the UNTIL terms
of INSERT_DATE so the two cannot drift apart.

Fractional day offsets are still unsupported.
This commit is contained in:
Kristian Bendiksen
2026-09-01 11:56:59 +02:00
committed by Magne Sjaastad
parent 98e39bc02f
commit 7867205468
2 changed files with 60 additions and 7 deletions
+14 -7
View File
@@ -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 <ident>`` 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<base>{_ISO_DATE}|{_IDENT})"
_TERMS = rf"(?P<terms>(?:\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>{_TERMS_BODY})"
_HEADER_RE = re.compile(r"^ORIONEVENTS\s+(?P<version>\d+\.\d+)$")
_UNIT_RE = re.compile(r"^UNIT\s+(?P<unit>METRIC|FIELD|LAB)$")
@@ -428,7 +433,7 @@ _INSERT_DATE_RE = re.compile(
rf"(?:\s+EVERY\s+(?:(?P<count>\d+)\s+)?"
rf"(?P<period>DAY|DAYS|MONTH|MONTHS|YEAR|YEARS)"
rf"(?:\s+UNTIL\s+(?P<end_base>{_ISO_DATE}|{_IDENT})"
rf"(?P<end_terms>(?:\s*[-+]\s*(?:\d+|{_IDENT}))*)"
rf"(?P<end_terms>{_TERMS_BODY})"
rf")?)?$"
)
_FILTER_DECL_RE = re.compile(rf'^FILTER\s+(?P<name>{_IDENT})\s*=\s*"(?P<expr>[^"]*)"$')
@@ -449,7 +454,7 @@ _RESULT_TYPE_ALIASES = {
_WELL_BLOCK_RE = re.compile(rf'^WELL\s+(?:"(?P<qname>[^"]*)"|(?P<ref>{_IDENT}))$')
_GROUP_BLOCK_RE = re.compile(r'^GROUP\s+"(?P<name>[^"]*)"$')
_EVENT_RE = re.compile(rf"^{_DATE_BASE}{_TERMS}\s+(?P<rest>.+)$")
_TERM_RE = re.compile(rf"([-+])\s*(\d+|{_IDENT})")
_TERM_RE = re.compile(rf"([-+])\s*({_SIGNED_INT}|{_IDENT})")
_ATTR_RE = re.compile(r'(?P<key>[A-Za-z_]\w*)\s*=\s*(?:"(?P<qval>[^"]*)"|(?P<val>\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
@@ -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"