From b3d0256941ee65e99df957b45a803fa1f9a0aada Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 14 Aug 2026 13:29:47 +0200 Subject: [PATCH] #14514 Add option to include list of dates into schedule file GenerateSchedule gains an AdditionalDates parameter (ISO date strings). The dates are merged, deduplicated and sorted with the event dates and each becomes a DATES keyword even when no events fall on it, ensuring a summary report at that date in Eclipse/Flow. They are deliberately not filtered by the last applied timestamp. The ORIONEVENTS 2.0 format gains a top-level REPORT directive (date variables and day arithmetic supported). Parsed dates are collected on OrionDocument.report_dates and surfaced by the applier as sorted ISO strings on ApplyReport.report_dates, ready to pass to generate_schedule_text(additional_dates=...). --- .../RimcWellEventTimeline.cpp | 25 +++ .../RimcWellEventTimeline.h | 1 + .../experimental/well_event_schedule_orion.py | 16 +- .../well_event_schedule.py | 6 +- .../example_input_files/well_events.orion | 5 + GrpcInterface/Python/rips/orion_events.py | 54 ++++++- .../Python/rips/tests/test_orion_events.py | 68 +++++++- .../Python/rips/tests/test_well_events.py | 147 ++++++++++++++++++ GrpcInterface/Python/rips/well_events.py | 11 ++ 9 files changed, 324 insertions(+), 9 deletions(-) diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp index 92fb7f26bc..1ef53ff637 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp @@ -592,6 +592,13 @@ RimcWellEventTimeline_generateSchedule::RimcWellEventTimeline_generateSchedule( "", "", "Emit a column-header comment and right-aligned, fixed-width columns instead of the compact form" ); + CAF_PDM_InitScriptableFieldNoDefault( &m_additionalDates, + "AdditionalDates", + "", + "", + "", + "Additional dates (YYYY-MM-DD or full ISO timestamp) emitted as DATES keywords, e.g. to " + "force summary reports at those dates" ); } //-------------------------------------------------------------------------------------------------- @@ -641,6 +648,24 @@ std::expected RimcWellEventTimeline_generateSche return std::unexpected( QString( "No well paths with events found" ) ); } + // Merge in user-specified additional dates: each becomes a DATES keyword even when no events + // fall on it (e.g. to force a summary report). They are deliberately not filtered by the last + // applied timestamp. + if ( !m_additionalDates().empty() ) + { + std::set mergedDates( dates.begin(), dates.end() ); + for ( const QString& dateString : m_additionalDates() ) + { + QDateTime additionalDate = QDateTime::fromString( dateString, Qt::ISODate ); + if ( !additionalDate.isValid() ) + { + return std::unexpected( QString( "Invalid date format: %1. Expected YYYY-MM-DD" ).arg( dateString ) ); + } + mergedDates.insert( additionalDate ); + } + dates.assign( mergedDates.begin(), mergedDates.end() ); + } + std::vector mswWellPaths = m_exportMswForWells.ptrReferencedObjectsByType(); std::set mswWells( mswWellPaths.begin(), mswWellPaths.end() ); diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h index 19772bae0e..fbeb3e4b49 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h @@ -231,4 +231,5 @@ private: caf::PdmPtrArrayField m_exportMswForWells; caf::PdmField m_firstDateAsComment; caf::PdmField m_alignColumns; + caf::PdmField> m_additionalDates; }; diff --git a/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py b/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py index 73df183e95..856c7dec78 100644 --- a/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py +++ b/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py @@ -16,7 +16,9 @@ It demonstrates the full event coverage of the format: 3. Well keyword events: WCONHIST and WELTARG (with attribute translation) and WRFTPLT (generic Eclipse well keyword pass-through) 4. SCHEDULE-level keyword events not tied to a well: RPTRST, GRUPTREE, TUNING -5. Generating Eclipse schedule text from the resulting timeline +5. REPORT dates, passed to generate_schedule_text(additional_dates=...) so + they appear as bare DATES keywords (summary-report triggers) +6. Generating Eclipse schedule text from the resulting timeline The ORIONEVENTS text is built inline with the name of the first well path in the project (like well_event_schedule.py, which uses wells[0]), so the example @@ -74,6 +76,11 @@ SCHEDULE @STARTUP RPTRST BASIC=2 FREQ=1 @STARTUP GRUPTREE CHILD=OP PARENT=FIELD @STARTUP TUNING TSINIT=1 TSMAXZ=30 TMAXWC=1 NEWTMX=12 NEWTMN=1 LITMAX=50 LITMIN=1 MXWSIT=50 MXWPIT=50 + +# Report dates: emitted as bare DATES keywords so Eclipse/Flow writes a +# summary report at these dates even though no events fall on them. +REPORT 2024-07-01 +REPORT STARTUP + 365 """ @@ -114,6 +121,7 @@ def main(): ) print(f" Events applied: {report.events_applied}") print(f" Events skipped: {report.events_skipped}") + print(f" Report dates: {report.report_dates}") for warning in report.warnings: print(f" WARNING: {warning}") for error in report.errors: @@ -139,8 +147,12 @@ def main(): if case is None: print(" No Eclipse case loaded - skipping schedule generation.") return + # REPORT dates from the ORIONEVENTS text become bare DATES keywords + # (summary-report triggers) via additional_dates. schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[well_path] + eclipse_case=case, + export_msw_for_wells=[well_path], + additional_dates=report.report_dates, ) if schedule_text: print(f" Generated schedule text ({len(schedule_text)} characters)") diff --git a/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py b/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py index 52d2efa5de..5d7ecb7b1a 100644 --- a/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py +++ b/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py @@ -251,8 +251,12 @@ def main(): # Generate schedule text. Pass the wells that should get multi-segment-well # keywords (WELSEGS, COMPSEGS, WSEGVALV, WSEGAICD); an empty list omits them. + # additional_dates are emitted as bare DATES keywords even when no events + # fall on them - in Eclipse/Flow a DATES entry ensures a summary report. schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[well_path] + eclipse_case=case, + export_msw_for_wells=[well_path], + additional_dates=["2024-07-01"], ) # Generate the same schedule with align_columns=True, which adds a "--"-prefixed diff --git a/GrpcInterface/Python/rips/example_input_files/well_events.orion b/GrpcInterface/Python/rips/example_input_files/well_events.orion index 972bfadcd3..4dbf5dbfa9 100644 --- a/GrpcInterface/Python/rips/example_input_files/well_events.orion +++ b/GrpcInterface/Python/rips/example_input_files/well_events.orion @@ -19,6 +19,11 @@ FILTER POROPERM = "PORO > 0.1 AND PERMX > 100.0" WELL A1 = "55_33-A-1" +# Report dates: each becomes a bare DATES keyword in the generated schedule +# (in Eclipse/Flow a DATES entry ensures a summary report at that date). +REPORT 2018-07-01 +REPORT A2_STARTUP + 90 + WELL A1 @A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1 FILTER=POROPERM @A1_STARTUP PERFORATION MDSTART=1664.28 MDEND=1674.18 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=2 FILTER=POROPERM diff --git a/GrpcInterface/Python/rips/orion_events.py b/GrpcInterface/Python/rips/orion_events.py index bd89cb852f..8dd72d296e 100644 --- a/GrpcInterface/Python/rips/orion_events.py +++ b/GrpcInterface/Python/rips/orion_events.py @@ -22,9 +22,10 @@ File format grammar, version 2.0 (EBNF-ish):: document = header , { statement } ; header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *) - statement = unit_directive | declaration | well_block_open + statement = unit_directive | declaration | report_line | well_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 *) @@ -75,6 +76,14 @@ Notes on the grammar: variables. A ``WELL`` line containing ``=`` is always a declaration. A bare ``SCHEDULE`` line opens a block of schedule-level keyword events not tied to any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal. +* ``REPORT `` (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. @@ -260,6 +269,9 @@ class OrionDocument: variables: Dict[str, OrionValue] = field(default_factory=dict) wells: List[WellBlock] = 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) @@ -267,7 +279,16 @@ class OrionDocument: # Layer A: pure parser # --------------------------------------------------------------------------- -_KEYWORDS = ("ORIONEVENTS", "UNIT", "DATE", "DURATION", "WELL", "FILTER", "SCHEDULE") +_KEYWORDS = ( + "ORIONEVENTS", + "UNIT", + "DATE", + "DURATION", + "WELL", + "FILTER", + "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+)?)?" @@ -282,6 +303,7 @@ _DURATION_DECL_RE = re.compile( r"(?:\s+(?:DAYS|days))?$" ) _WELL_DECL_RE = re.compile(rf'^WELL\s+(?P{_IDENT})\s*=\s*"(?P[^"]*)"$') +_REPORT_RE = re.compile(rf"^REPORT\s+{_DATE_BASE}{_TERMS}$") _FILTER_DECL_RE = re.compile(rf'^FILTER\s+(?P{_IDENT})\s*=\s*"(?P[^"]*)"$') _FILTER_SPLIT_RE = re.compile(r"\s+(AND|OR)\s+") _NUMBER = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?" @@ -321,6 +343,7 @@ def parse_orion_events(text: str) -> OrionDocument: variables: Dict[str, OrionValue] = {} wells: List[WellBlock] = [] 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 @@ -352,6 +375,7 @@ def parse_orion_events(text: str) -> OrionDocument: variables, wells, schedule_events, + report_dates, warnings, current_events, unit_holder, @@ -377,6 +401,7 @@ def parse_orion_events(text: str) -> OrionDocument: variables=variables, wells=wells, schedule_events=schedule_events, + report_dates=report_dates, warnings=warnings, ) @@ -404,6 +429,7 @@ def _parse_line( variables: Dict[str, OrionValue], wells: List[WellBlock], schedule_events: List[OrionEvent], + report_dates: List[Union[datetime.date, datetime.datetime]], warnings: List[ParseWarning], current_events: Optional[List[OrionEvent]], unit_holder: List[str], @@ -435,6 +461,19 @@ def _parse_line( ) 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 [+|- ...])", + 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: @@ -805,6 +844,7 @@ class ApplyReport: 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) @@ -890,11 +930,16 @@ def apply_orion_document( event types are passed through as generic Eclipse keywords. Returns: - ApplyReport: counts plus collected warnings/errors. + 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") report = ApplyReport() + report.report_dates = sorted({d.isoformat() for d in document.report_dates}) ctx = _prepare_filter_context(document, project, case) @@ -1398,7 +1443,8 @@ def _cli(argv: Optional[List[str]] = None) -> int: print( f" {len(document.variables)} variable(s), {len(document.wells)} " f"well block(s), {event_count} well event(s), " - f"{len(document.schedule_events)} schedule 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}") diff --git a/GrpcInterface/Python/rips/tests/test_orion_events.py b/GrpcInterface/Python/rips/tests/test_orion_events.py index 0e2885340e..c3345afa00 100644 --- a/GrpcInterface/Python/rips/tests/test_orion_events.py +++ b/GrpcInterface/Python/rips/tests/test_orion_events.py @@ -402,6 +402,48 @@ class TestParsing: with pytest.raises(OrionParseError, match="SCHEDULE takes no arguments"): parse_orion_events("ORIONEVENTS 2.0\nSCHEDULE NOW\n") + def test_report_lines_parse(self): + text = ( + "ORIONEVENTS 2.0\n" + "DATE START = 2024-01-01\n" + "REPORT 2024-06-01\n" + "REPORT START + 31\n" + ) + doc = parse_orion_events(text) + assert doc.report_dates == [ + datetime.date(2024, 6, 1), + datetime.date(2024, 2, 1), + ] + + def test_report_keeps_duplicates_and_file_order(self): + text = "ORIONEVENTS 2.0\nREPORT 2024-06-01\nREPORT 2024-06-01\n" + doc = parse_orion_events(text) + assert doc.report_dates == [datetime.date(2024, 6, 1)] * 2 + + def test_report_inside_block_does_not_close_it(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "W"\n' + " @2024-01-01 WCONHIST STATUS=OPEN\n" + "REPORT 2024-06-01\n" + " @2024-02-01 WELTARG CMODE=ORAT VALUE=5000\n" + ) + doc = parse_orion_events(text) + assert [len(w.events) for w in doc.wells] == [2] + assert doc.report_dates == [datetime.date(2024, 6, 1)] + + def test_report_with_undeclared_variable_raises(self): + with pytest.raises(OrionParseError, match="NOPE"): + parse_orion_events("ORIONEVENTS 2.0\nREPORT NOPE + 1\n") + + def test_malformed_report_line_raises(self): + with pytest.raises(OrionParseError, match="Malformed REPORT line"): + parse_orion_events("ORIONEVENTS 2.0\nREPORT\n") + + def test_report_with_datetime_literal(self): + text = "ORIONEVENTS 2.0\nREPORT 2024-06-01T14:45:30.500\n" + doc = parse_orion_events(text) + assert doc.report_dates == [datetime.datetime(2024, 6, 1, 14, 45, 30, 500000)] + def test_datetime_literal_event(self): text = ( 'ORIONEVENTS 2.0\nWELL "W"\n' @@ -722,6 +764,20 @@ class TestApplying: assert timeline.keyword_calls[0]["event_date"] == "2018-01-01" assert any("DSHIFT" in w for w in report.warnings) + def test_report_dates_on_apply_report(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' + " @2018-01-01 WCONHIST STATUS=OPEN\n" + "REPORT 2018-07-01\n" + "REPORT 2018-03-01\n" + "REPORT 2018-07-01\n" + ) + timeline, report = self._apply(text) + # Sorted, deduplicated ISO strings ready for + # generate_schedule_text(additional_dates=...). No timeline events. + assert report.report_dates == ["2018-03-01", "2018-07-01"] + assert report.events_applied == 1 + def test_perfid_on_perforation_warns_and_applies(self): text = ( 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' @@ -1051,11 +1107,13 @@ class TestOrionEventsIntegration: " @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" + "REPORT 2024-07-01\n" ) document = parse_orion_events(text) report = apply_orion_document(document, timeline, project) assert report.errors == [] assert report.events_applied == 3 + assert report.report_dates == ["2024-07-01"] # Materialize completions from the perforation event. timeline.set_timestamp(timestamp="2024-01-15") @@ -1066,13 +1124,19 @@ class TestOrionEventsIntegration: assert abs(perf.start_measured_depth - 2000.0) < 1.0 assert abs(perf.end_measured_depth - 2200.0) < 1.0 - # The generated schedule should carry the mapped keywords. + # The generated schedule should carry the mapped keywords, and the + # REPORT date should appear as a bare DATES entry (issue #14514). schedule = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[] + eclipse_case=case, + export_msw_for_wells=[], + additional_dates=report.report_dates, ) assert "COMPDAT" in schedule assert "WCONHIST" in schedule assert "WELTARG" in schedule + assert "1 'JUL' 2024" in schedule, ( + "REPORT date should be emitted as a DATES entry" + ) def test_apply_full_event_coverage_and_schedule(self, project_with_case_and_wells): """All event kinds from well_event_schedule.py expressed as ORIONEVENTS.""" diff --git a/GrpcInterface/Python/rips/tests/test_well_events.py b/GrpcInterface/Python/rips/tests/test_well_events.py index 196feb0556..d2864ca08a 100644 --- a/GrpcInterface/Python/rips/tests/test_well_events.py +++ b/GrpcInterface/Python/rips/tests/test_well_events.py @@ -437,6 +437,153 @@ class TestScheduleGeneration: assert "DATES" in schedule_text, "Schedule should contain DATES keyword" assert "2024" in schedule_text, "Schedule should contain event dates" + def test_generate_schedule_with_additional_dates(self, project_with_case_and_well): + """Additional dates become bare DATES keywords, merged chronologically (issue #14514).""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.add_perf_event( + event_date="2024-03-01", + well_path=well_path_a, + start_md=2000.0, + end_md=2200.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + additional_dates=["2024-02-01", "2024-06-01"], + ) + + assert "1 'FEB' 2024" in schedule_text, ( + "Additional date 2024-02-01 should be emitted as a DATES entry" + ) + assert "1 'JUN' 2024" in schedule_text, ( + "Additional date 2024-06-01 should be emitted as a DATES entry" + ) + # Dates must appear in chronological order: JAN (event), FEB, MAR (event), JUN + positions = [ + schedule_text.index(date_str) + for date_str in [ + "1 'JAN' 2024", + "1 'FEB' 2024", + "1 'MAR' 2024", + "1 'JUN' 2024", + ] + ] + assert positions == sorted(positions), ( + f"Dates should appear chronologically, got positions {positions}" + ) + + def test_additional_dates_deduplicated_and_merged(self, project_with_case_and_well): + """An additional date equal to an event date must not produce a duplicate DATES entry.""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + additional_dates=["2024-01-01", "2024-01-01"], + ) + + assert schedule_text.count("1 'JAN' 2024") == 1, ( + "Duplicate additional dates should be merged with the event date" + ) + + def test_additional_dates_invalid_format(self, project_with_case_and_well): + """An unparsable additional date must raise an error mentioning the format.""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + with pytest.raises(rips.RipsError) as exc_info: + timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + additional_dates=["not-a-date"], + ) + assert "Invalid date format" in str(exc_info.value) + + def test_additional_date_before_first_event(self, project_with_case_and_well): + """An additional date earlier than all events becomes the first date of the schedule.""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + # Default first_date_as_comment=True: the earliest date (the additional + # one) becomes the comment; the event date is a real DATES entry. + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + additional_dates=["2023-06-01"], + ) + assert "-- Date: 1 JUN 2023" in schedule_text, ( + "Earliest (additional) date should be emitted as a comment by default" + ) + assert "1 'JAN' 2024" in schedule_text, ( + "Event date should be a DATES entry when it is no longer first" + ) + + # With first_date_as_comment=False every date is a DATES entry. + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + additional_dates=["2023-06-01"], + ) + assert "1 'JUN' 2023" in schedule_text, ( + "Additional date should be a DATES entry with first_date_as_comment=False" + ) + def test_first_date_as_comment(self, project_with_case_and_well): """Test that the first (earliest) date can be emitted as a comment. diff --git a/GrpcInterface/Python/rips/well_events.py b/GrpcInterface/Python/rips/well_events.py index 776d0afa8a..2904b87b61 100644 --- a/GrpcInterface/Python/rips/well_events.py +++ b/GrpcInterface/Python/rips/well_events.py @@ -290,6 +290,7 @@ def generate_schedule_text( export_msw_for_wells: List[WellPath] = [], first_date_as_comment: bool = True, align_columns: bool = False, + additional_dates: List[str] = [], ) -> str: """Generate Eclipse schedule text for all wells in the collection. @@ -314,6 +315,15 @@ def generate_schedule_text( align_columns (bool): When True, emit each keyword with a "--"-prefixed column-header comment and right-aligned, fixed-width columns instead of the compact default form. Defaults to False. + additional_dates (List[str]): Additional dates ("YYYY-MM-DD" or a full + ISO timestamp such as "2024-05-15T14:45:30") emitted as DATES + keywords even when no events fall on them. In Eclipse/Flow a DATES + entry ensures a summary report at that date. The dates are merged, + deduplicated and sorted together with the event dates, and are not + filtered by set_timestamp(). If an additional date precedes all + event dates it becomes the earliest date and is therefore emitted + as a comment when first_date_as_comment is True; pass + first_date_as_comment=False to emit every date as a DATES keyword. Returns: str: Eclipse schedule text containing DATES, COMPDAT, WELSEGS, WCONPROD, etc. @@ -358,6 +368,7 @@ def generate_schedule_text( export_msw_for_wells=export_msw_for_wells, first_date_as_comment=first_date_as_comment, align_columns=align_columns, + additional_dates=additional_dates, ) if container and container.values: return "".join(container.values)