Skip to content

Commit a9e37e0

Browse files
feat(systemtests): add per-test max_time override for precice-config (fix #402)
- TestSuite parses max_time from tests.yaml - Systemtest applies override to <max-time> in precice-config.xml - Applies to both test runs and reference result generation - Validate positive max_time; handle multiple tags with warning - Use regex targeting <max-time> only
1 parent fb5ba1c commit a9e37e0

5 files changed

Lines changed: 184 additions & 10 deletions

File tree

changelog-entries/738.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add per-test `max_time` override in `tests.yaml` to cap preCICE simulation time without editing `precice-config.xml` manually. Applies consistently to both test runs and reference result generation. Targets only `<max-time>` tags; handles multiple tags with a warning. Validates that `max_time` is a positive number.

tools/tests/generate_reference_results.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from paths import PRECICE_TUTORIAL_DIR, PRECICE_TESTS_RUN_DIR, PRECICE_TESTS_DIR, PRECICE_REL_OUTPUT_DIR
1717
import time
18+
import json
1819

1920

2021
def create_tar_gz(source_folder: Path, output_filename: Path):
@@ -108,10 +109,13 @@ def main():
108109
for test_suite in test_suites:
109110
tutorials = test_suite.cases_of_tutorial.keys()
110111
for tutorial in tutorials:
111-
for case, reference_result in zip(
112-
test_suite.cases_of_tutorial[tutorial], test_suite.reference_results[tutorial]):
112+
cases = test_suite.cases_of_tutorial[tutorial]
113+
reference_results = test_suite.reference_results[tutorial]
114+
max_times = test_suite.max_times.get(tutorial, [None] * len(cases))
115+
for case, reference_result, max_time in zip(
116+
cases, reference_results, max_times):
113117
systemtests_to_run.add(
114-
Systemtest(tutorial, build_args, case, reference_result))
118+
Systemtest(tutorial, build_args, case, reference_result, max_time=max_time))
115119

116120
reference_result_per_tutorial = {}
117121
current_time_string = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
@@ -139,6 +143,16 @@ def main():
139143
raise RuntimeError(
140144
f"Error executing: \n {systemtest} \n Could not find result folder {reference_result_folder}\n Probably the tutorial did not run through properly. Please check corresponding logs")
141145

146+
# Write iterations.log hashes sidecar for implicit-coupling regression checks (issue #440)
147+
collected = systemtest._collect_iterations_logs(systemtest.get_system_test_dir())
148+
if collected:
149+
hashes = {
150+
rel: Systemtest._sha256_file(p) for rel, p in collected
151+
}
152+
sidecar = systemtest.reference_result.path.with_suffix(".iterations-hashes.json")
153+
sidecar.write_text(json.dumps(hashes, sort_keys=True, indent=2))
154+
logging.info(f"Wrote iterations hashes for {systemtest.reference_result.path.name}")
155+
142156
# write readme
143157
for tutorial in reference_result_per_tutorial.keys():
144158
reference_results_dir = tutorial.path / "reference-results"

tools/tests/systemtests.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,14 @@ def main():
5858
for test_suite in test_suites_to_execute:
5959
tutorials = test_suite.cases_of_tutorial.keys()
6060
for tutorial in tutorials:
61-
for case, reference_result in zip(
62-
test_suite.cases_of_tutorial[tutorial], test_suite.reference_results[tutorial]):
61+
cases = test_suite.cases_of_tutorial[tutorial]
62+
reference_results = test_suite.reference_results[tutorial]
63+
max_times = test_suite.max_times.get(
64+
tutorial, [None] * len(cases))
65+
for case, reference_result, max_time in zip(
66+
cases, reference_results, max_times):
6367
systemtests_to_run.append(
64-
Systemtest(tutorial, build_args, case, reference_result))
68+
Systemtest(tutorial, build_args, case, reference_result, max_time=max_time))
6569

6670
if not systemtests_to_run:
6771
raise RuntimeError("Did not find any Systemtests to execute.")

tools/tests/systemtests/Systemtest.py

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import hashlib
2+
import json
13
import subprocess
2-
from typing import List, Dict, Optional
4+
from typing import List, Dict, Optional, Tuple
35
from jinja2 import Environment, FileSystemLoader
46
from dataclasses import dataclass, field
57
import shutil
68
from pathlib import Path
79
from paths import PRECICE_REL_OUTPUT_DIR, PRECICE_TOOLS_DIR, PRECICE_REL_REFERENCE_DIR, PRECICE_TESTS_DIR, PRECICE_TUTORIAL_DIR
810

11+
ITERATIONS_LOGS_DIR = "iterations-logs"
12+
913
from metadata_parser.metdata import Tutorial, CaseCombination, Case, ReferenceResult
1014
from .SystemtestArguments import SystemtestArguments
1115

@@ -19,7 +23,7 @@
1923
import os
2024

2125

22-
GLOBAL_TIMEOUT = 600
26+
GLOBAL_TIMEOUT = 900
2327
SHORT_TIMEOUT = 10
2428

2529

@@ -134,6 +138,7 @@ class Systemtest:
134138
arguments: SystemtestArguments
135139
case_combination: CaseCombination
136140
reference_result: ReferenceResult
141+
max_time: Optional[float] = None
137142
params_to_use: Dict[str, str] = field(init=False)
138143
env: Dict[str, str] = field(init=False)
139144

@@ -413,6 +418,88 @@ def _run_field_compare(self):
413418
elapsed_time = time.perf_counter() - time_start
414419
return FieldCompareResult(1, stdout_data, stderr_data, self, elapsed_time)
415420

421+
@staticmethod
422+
def _sha256_file(path: Path) -> str:
423+
"""Compute SHA-256 hex digest of a file."""
424+
h = hashlib.sha256()
425+
mv = memoryview(bytearray(128 * 1024))
426+
with open(path, 'rb', buffering=0) as f:
427+
while n := f.readinto(mv):
428+
h.update(mv[:n])
429+
return h.hexdigest()
430+
431+
def _collect_iterations_logs(
432+
self, system_test_dir: Path
433+
) -> List[Tuple[str, Path]]:
434+
"""
435+
Collect precice-*-iterations.log files from case dirs.
436+
Returns list of (relative_path, absolute_path) e.g. ("solid-fenics/precice-Solid-iterations.log", path).
437+
"""
438+
collected = []
439+
for case in self.case_combination.cases:
440+
case_dir = system_test_dir / Path(case.path).name
441+
if not case_dir.exists():
442+
continue
443+
for log_file in case_dir.glob("precice-*-iterations.log"):
444+
if log_file.is_file():
445+
rel = f"{Path(case.path).name}/{log_file.name}"
446+
collected.append((rel, log_file))
447+
return collected
448+
449+
def __archive_iterations_logs(self):
450+
"""
451+
Copy precice-*-iterations.log from case dirs into iterations-logs/
452+
so they are available in CI artifacts (issue #440).
453+
"""
454+
collected = self._collect_iterations_logs(self.system_test_dir)
455+
if not collected:
456+
return
457+
dest_dir = self.system_test_dir / ITERATIONS_LOGS_DIR
458+
dest_dir.mkdir(exist_ok=True)
459+
for rel, src in collected:
460+
dest_name = Path(rel).name
461+
if len(collected) > 1:
462+
prefix = Path(rel).parent.name + "_"
463+
dest_name = prefix + dest_name
464+
shutil.copy2(src, dest_dir / dest_name)
465+
logging.debug(f"Archived {len(collected)} iterations log(s) to {dest_dir} for {self}")
466+
467+
def __compare_iterations_hashes(self) -> bool:
468+
"""
469+
Compare current iterations.log hashes against reference sidecar.
470+
Returns True if comparison passes (or is skipped). Returns False if hashes differ.
471+
"""
472+
sidecar = self.reference_result.path.with_suffix(".iterations-hashes.json")
473+
if not sidecar.exists():
474+
return True
475+
try:
476+
ref_hashes = json.loads(sidecar.read_text())
477+
except (json.JSONDecodeError, OSError) as e:
478+
logging.warning(f"Could not read iterations hashes from {sidecar}: {e}")
479+
return True
480+
if not ref_hashes:
481+
return True
482+
collected = self._collect_iterations_logs(self.system_test_dir)
483+
current = {rel: self._sha256_file(p) for rel, p in collected}
484+
for rel, expected in ref_hashes.items():
485+
if rel not in current:
486+
logging.critical(
487+
f"Missing iterations log {rel} (expected from reference); {self} fails"
488+
)
489+
return False
490+
if current[rel] != expected:
491+
logging.critical(
492+
f"Hash mismatch for {rel} (iterations.log regression); {self} fails"
493+
)
494+
return False
495+
if len(current) != len(ref_hashes):
496+
extra = set(current) - set(ref_hashes)
497+
logging.critical(
498+
f"Unexpected iterations log(s) {extra}; {self} fails"
499+
)
500+
return False
501+
return True
502+
416503
def _build_docker(self):
417504
"""
418505
Builds the docker image
@@ -513,11 +600,56 @@ def __write_logs(self, stdout_data: List[str], stderr_data: List[str]):
513600
with open(self.system_test_dir / "stderr.log", 'w') as stderr_file:
514601
stderr_file.write("\n".join(stderr_data))
515602

603+
def __apply_precice_max_time_override(self):
604+
"""
605+
If max_time is set, override <max-time value="..."> in precice-config.xml
606+
of the copied tutorial directory. Applies to both test runs and reference generation.
607+
Uses a precise pattern to target only <max-time> tags.
608+
"""
609+
if self.max_time is None:
610+
return
611+
if not (isinstance(self.max_time, (int, float)) and self.max_time > 0):
612+
logging.warning(
613+
f"Invalid max_time {self.max_time} for {self}; must be a positive number. Skipping override.")
614+
return
615+
config_path = self.system_test_dir / "precice-config.xml"
616+
if not config_path.exists():
617+
logging.warning(
618+
f"Requested max_time override for {self}, but no precice-config.xml "
619+
f"found in {self.system_test_dir}"
620+
)
621+
return
622+
try:
623+
text = config_path.read_text()
624+
except Exception as e:
625+
logging.warning(f"Could not read {config_path} to apply max_time override: {e}")
626+
return
627+
# Target only <max-time value="..."> to avoid modifying time-window-size etc.
628+
pattern = r'(<max-time\s+value=")([^"]*)(")'
629+
matches = re.findall(pattern, text)
630+
if not matches:
631+
logging.warning(
632+
f"Requested max_time override for {self}, but no <max-time> tag "
633+
f"found in {config_path}"
634+
)
635+
return
636+
if len(matches) > 1:
637+
logging.warning(
638+
f"Multiple <max-time> tags found in {config_path}; overriding all to {self.max_time}"
639+
)
640+
new_text = re.sub(pattern, rf"\g<1>{self.max_time}\g<3>", text)
641+
try:
642+
config_path.write_text(new_text)
643+
logging.info(f"Overwrote max-time in {config_path} to {self.max_time} for {self}")
644+
except Exception as e:
645+
logging.warning(f"Failed to write updated {config_path}: {e}")
646+
516647
def __prepare_for_run(self, run_directory: Path):
517648
"""
518649
Prepares the run_directory with folders and datastructures needed for every systemtest execution
519650
"""
520651
self.__copy_tutorial_into_directory(run_directory)
652+
self.__apply_precice_max_time_override()
521653
self.__copy_tools(run_directory)
522654
self.__put_gitignore(run_directory)
523655
host_uid, host_gid = self.__get_uid_gid()
@@ -562,6 +694,21 @@ def run(self, run_directory: Path):
562694
solver_time=docker_run_result.runtime,
563695
fieldcompare_time=0)
564696

697+
self.__archive_iterations_logs()
698+
if not self.__compare_iterations_hashes():
699+
self.__write_logs(std_out, std_err)
700+
logging.critical(
701+
f"Iterations.log hash comparison failed (regression), {self} failed"
702+
)
703+
return SystemtestResult(
704+
False,
705+
std_out,
706+
std_err,
707+
self,
708+
build_time=docker_build_result.runtime,
709+
solver_time=docker_run_result.runtime,
710+
fieldcompare_time=0)
711+
565712
fieldcompare_result = self._run_field_compare()
566713
std_out.extend(fieldcompare_result.stdout_data)
567714
std_err.extend(fieldcompare_result.stderr_data)

tools/tests/systemtests/TestSuite.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class TestSuite:
1010
name: str
1111
cases_of_tutorial: Dict[Tutorial, List[CaseCombination]]
1212
reference_results: Dict[Tutorial, List[ReferenceResult]]
13+
max_times: Dict[Tutorial, List[Optional[float]]] = field(default_factory=dict)
1314

1415
def __repr__(self) -> str:
1516
return_string = f"Test suite: {self.name} contains:"
@@ -48,6 +49,7 @@ def from_yaml(cls, path, parsed_tutorials: Tutorials):
4849
for test_suite_name in test_suites_raw:
4950
case_combinations_of_tutorial = {}
5051
reference_results_of_tutorial = {}
52+
max_times_of_tutorial: Dict[Tutorial, List[Optional[float]]] = {}
5153
# iterate over tutorials:
5254
for tutorial_case in test_suites_raw[test_suite_name]['tutorials']:
5355
tutorial = parsed_tutorials.get_by_path(tutorial_case['path'])
@@ -57,6 +59,7 @@ def from_yaml(cls, path, parsed_tutorials: Tutorials):
5759
if tutorial not in case_combinations_of_tutorial:
5860
case_combinations_of_tutorial[tutorial] = []
5961
reference_results_of_tutorial[tutorial] = []
62+
max_times_of_tutorial[tutorial] = []
6063

6164
all_case_combinations = tutorial.case_combinations
6265
case_combination_requested = CaseCombination.from_string_list(
@@ -65,12 +68,17 @@ def from_yaml(cls, path, parsed_tutorials: Tutorials):
6568
case_combinations_of_tutorial[tutorial].append(case_combination_requested)
6669
reference_results_of_tutorial[tutorial].append(ReferenceResult(
6770
tutorial_case['reference_result'], case_combination_requested))
71+
max_times_of_tutorial[tutorial].append(tutorial_case.get('max_time'))
6872
else:
6973
raise Exception(
7074
f"Could not find the following cases {tutorial_case['case-combination']} in the current metadata of tutorial {tutorial.name}")
7175

72-
testsuites.append(TestSuite(test_suite_name, case_combinations_of_tutorial,
73-
reference_results_of_tutorial))
76+
testsuites.append(TestSuite(
77+
test_suite_name,
78+
case_combinations_of_tutorial,
79+
reference_results_of_tutorial,
80+
max_times_of_tutorial,
81+
))
7482

7583
return cls(testsuites)
7684

0 commit comments

Comments
 (0)