Skip to content

Commit c61c76c

Browse files
committed
Add short/long test duration support and auto-clip regression comparisons
Introduce getSimDuration() to select between short and full-length simulation durations via HYDROCHRONO_LONG_TESTS env var. Comparison scripts now automatically clip to the common time range so a single set of reference data works for both short and long runs. Improve regression report formatting and pass/fail status tracking.
1 parent 473cb85 commit c61c76c

29 files changed

Lines changed: 596 additions & 107 deletions

build.ps1

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ if ($Help) {
7171
Write-Host " .\build.ps1 -Clean -Verbose # Clean build with details"
7272
Write-Host " .\build.ps1 -Package # Build and create ZIP`n"
7373

74+
Write-Host "LONG TESTS:" -ForegroundColor Yellow
75+
Write-Host " Set `$env:HYDROCHRONO_LONG_TESTS='1' before running ctest to use extended"
76+
Write-Host " simulation durations (e.g. OSWEC reg waves: 240s -> 1000s) for publication-"
77+
Write-Host " quality regression reports.`n"
78+
7479
Write-Host "CONFIG FILE:" -ForegroundColor Yellow
7580
Write-Host ' { "ChronoDir": "C:/path/to/chrono/build/cmake" }' -ForegroundColor Gray
7681
Write-Host ""
@@ -361,10 +366,15 @@ Write-Host "BUILD SUCCESSFUL" -ForegroundColor Green
361366
Write-Host "========================================`n" -ForegroundColor Green
362367

363368
Write-Host "Output: $binPath" -ForegroundColor Cyan
369+
Write-Host ""
364370
Write-Host "Tests: ctest -C $BuildType -L regression --test-dir build" -ForegroundColor Gray
365371
Write-Host " ctest -C $BuildType -L unit --test-dir build" -ForegroundColor Gray
366372
Write-Host " Add -V for verbose output, --output-on-failure for failures only" -ForegroundColor DarkGray
367373
Write-Host ""
374+
Write-Host "Long: `$env:HYDROCHRONO_LONG_TESTS='1'" -ForegroundColor Gray
375+
Write-Host " ctest -C $BuildType -L regression --test-dir build" -ForegroundColor Gray
376+
Write-Host " Runs with extended simulation durations" -ForegroundColor DarkGray
377+
Write-Host ""
368378
Write-Host "Report: python tests/regression/utilities/generate_report.py --build-dir build --pdf" -ForegroundColor Gray
369379
Write-Host " Generates regression test report (markdown + PDF) in build/bin/report/" -ForegroundColor DarkGray
370380
Write-Host " Requires: pip install pypandoc (or pandoc on PATH)" -ForegroundColor DarkGray

include/hydroc/helper.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ bool GetCLIArguments(int argc,
5454
*/
5555
bool SetInitialEnvironment(const std::string& data_dir) noexcept;
5656

57+
/**@brief Get simulation duration based on HYDROCHRONO_LONG_TESTS environment variable.
58+
*
59+
* Tests use shorter durations by default for fast CI/regression checks.
60+
* Set HYDROCHRONO_LONG_TESTS=1 to use longer durations for publication-quality results.
61+
*
62+
* @param short_duration Duration (seconds) for quick testing
63+
* @param long_duration Duration (seconds) for thorough/publication testing
64+
* @return short_duration normally, long_duration when HYDROCHRONO_LONG_TESTS=1
65+
*/
66+
double getSimDuration(double short_duration, double long_duration) noexcept;
67+
5768
/**@brief Get base name of data directory
5869
*
5970
* @return the string containing the path in standard format

src/hydro/utils/helper.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ bool hydroc::SetInitialEnvironment(const std::string& data_dir) noexcept {
106106
return true;
107107
}
108108

109+
double hydroc::getSimDuration(double short_duration, double long_duration) noexcept {
110+
const char* env = std::getenv("HYDROCHRONO_LONG_TESTS");
111+
if (env) {
112+
std::string val(env);
113+
if (val == "1" || val == "true" || val == "TRUE" || val == "ON")
114+
return long_duration;
115+
}
116+
return short_duration;
117+
}
118+
109119
std::string hydroc::getDataDir() noexcept {
110120
return DATADIR.lexically_normal().generic_string();
111121
}

tests/regression/compare_template.py

Lines changed: 148 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
},
5757
'system_info': {
5858
'pos': (0.85, 0.82, 0.22, 0.12),
59-
'font_size': 'body',
59+
'font_size': 'small',
6060
'style': {
6161
'facecolor': '#f8f9fa',
6262
'edgecolor': '#e9ecef',
@@ -176,8 +176,58 @@ def get_hydrochrono_version():
176176
except (OSError, IOError, UnicodeDecodeError):
177177
return os.environ.get('HYDROCHRONO_VERSION', 'Unknown')
178178

179+
def _chrono_git_suffix(chrono_root):
180+
"""Return a git-based suffix like ' (branch@abc1234)' if Chrono is a dev build.
181+
182+
Reads git metadata directly from the .git directory so that the git
183+
executable does not need to be on PATH (common in CTest environments on
184+
Windows).
185+
"""
186+
try:
187+
git_dir = os.path.join(chrono_root, '.git')
188+
if not os.path.isdir(git_dir):
189+
return ''
190+
191+
head_file = os.path.join(git_dir, 'HEAD')
192+
with open(head_file, 'r', encoding='utf-8') as f:
193+
head = f.read().strip()
194+
195+
if head.startswith('ref: '):
196+
ref = head[5:] # e.g. 'refs/heads/feature/fsi'
197+
branch = ref.split('refs/heads/', 1)[-1] if 'refs/heads/' in ref else ref
198+
199+
# Resolve the commit hash from the ref
200+
ref_file = os.path.join(git_dir, ref.replace('/', os.sep))
201+
commit = None
202+
if os.path.isfile(ref_file):
203+
with open(ref_file, 'r', encoding='utf-8') as f:
204+
commit = f.read().strip()[:7]
205+
else:
206+
# Ref may be in packed-refs
207+
packed = os.path.join(git_dir, 'packed-refs')
208+
if os.path.isfile(packed):
209+
with open(packed, 'r', encoding='utf-8') as f:
210+
for line in f:
211+
if line.startswith('#'):
212+
continue
213+
parts = line.strip().split()
214+
if len(parts) == 2 and parts[1] == ref:
215+
commit = parts[0][:7]
216+
break
217+
if not commit:
218+
return f' ({branch})'
219+
else:
220+
branch = 'detached'
221+
commit = head[:7]
222+
223+
return f' ({branch}@{commit})'
224+
except Exception:
225+
return ''
226+
227+
179228
def get_chrono_version():
180-
"""Get Chrono version from Chrono CMakeLists.txt"""
229+
"""Get Chrono version from Chrono CMakeLists.txt, with git branch/hash
230+
appended when building from a development branch."""
181231
try:
182232
cmake_cache_path = get_cmake_cache_path()
183233
if not cmake_cache_path:
@@ -191,6 +241,9 @@ def get_chrono_version():
191241
chrono_dir = line.split('=')[1].strip()
192242
break
193243

244+
chrono_root = None
245+
version_str = None
246+
194247
if chrono_dir:
195248
# Navigate to Chrono root directory
196249
chrono_root = os.path.dirname(os.path.dirname(chrono_dir))
@@ -202,7 +255,6 @@ def get_chrono_version():
202255
for line in f:
203256
line = line.strip()
204257
if line.startswith('set(CHRONO_VERSION_MAJOR'):
205-
# Extract number from: set(CHRONO_VERSION_MAJOR 9)
206258
parts = line.split()
207259
if len(parts) >= 2:
208260
major = parts[1].rstrip(')')
@@ -216,9 +268,40 @@ def get_chrono_version():
216268
patch = parts[1].rstrip(')')
217269

218270
if major != "0" or minor != "0" or patch != "0":
219-
return f"{major}.{minor}.{patch}"
271+
version_str = f"{major}.{minor}.{patch}"
220272

221-
return os.environ.get('CHRONO_VERSION', 'Unknown')
273+
if not version_str:
274+
version_str = os.environ.get('CHRONO_VERSION', 'Unknown')
275+
276+
# Locate the Chrono source tree for git metadata.
277+
# Chrono_DIR is typically <source>/build/cmake, so:
278+
# chrono_build_dir = dirname(Chrono_DIR) = <source>/build
279+
# chrono_root = dirname(dirname(Chrono_DIR)) = <source>
280+
git_suffix = ''
281+
if chrono_dir:
282+
chrono_build_dir = os.path.dirname(chrono_dir)
283+
source_candidates = [chrono_root] if chrono_root else []
284+
285+
# Read Chrono's own build CMakeCache for the definitive source dir
286+
chrono_build_cache = os.path.join(chrono_build_dir, 'CMakeCache.txt')
287+
if os.path.exists(chrono_build_cache):
288+
try:
289+
with open(chrono_build_cache, 'r', encoding='utf-8') as f:
290+
for ln in f:
291+
if ln.startswith('CMAKE_HOME_DIRECTORY:INTERNAL='):
292+
candidate = ln.split('=', 1)[1].strip()
293+
if candidate:
294+
source_candidates.insert(0, candidate)
295+
break
296+
except Exception:
297+
pass
298+
299+
for src in source_candidates:
300+
if os.path.isdir(os.path.join(src, '.git')):
301+
git_suffix = _chrono_git_suffix(src)
302+
break
303+
304+
return version_str + git_suffix
222305
except (OSError, IOError, UnicodeDecodeError):
223306
return os.environ.get('CHRONO_VERSION', 'Unknown')
224307

@@ -253,36 +336,42 @@ def apply_modern_style(ax):
253336
ax.spines[spine].set_linewidth(1.0)
254337
ax.set_facecolor('#ffffff')
255338

339+
_NON_EXECUTABLE_EXTS = {'.txt', '.py', '.csv', '.json', '.md', '.log', '.png',
340+
'.jpg', '.svg', '.pdf', '.h5', '.hdf5', '.dat', '.status'}
341+
256342
def find_executable(test_dir, executable_patterns):
257343
"""
258-
Find executable in test directory or its parent
344+
Find executable in test directory or ancestor directories.
259345
260346
Args:
261-
test_dir: Directory to search in
347+
test_dir: Directory to start searching from
262348
executable_patterns: List of patterns to search for (e.g., ["sphere_decay_test", "rm3_test"])
263349
264350
Returns:
265351
Path to executable if found, None otherwise
266352
"""
267-
search_dirs = [test_dir, test_dir.parent]
353+
search_dirs = [test_dir]
354+
cur = test_dir
355+
for _ in range(4):
356+
cur = cur.parent
357+
search_dirs.append(cur)
268358

269359
try:
270360
for s_dir in search_dirs:
271361
for pattern in executable_patterns:
272-
# Look for common executable names
273362
possible_names = [pattern, f"{pattern}.exe", f"{pattern}.out"]
274363

275364
for name in possible_names:
276365
exe_file = s_dir / name
277-
if exe_file.exists():
366+
if exe_file.exists() and exe_file.suffix not in _NON_EXECUTABLE_EXTS:
278367
return exe_file
279368

280-
# If not found, look for any executable with the pattern in the name
281369
for exe_file in s_dir.glob("*"):
282-
if exe_file.is_file() and pattern in exe_file.name:
283-
# Check if it's executable (Unix) or has executable extension (Windows)
284-
if (os.access(exe_file, os.X_OK) or
285-
exe_file.suffix in ['.exe', '.out', '.app']):
370+
if not exe_file.is_file() or exe_file.suffix in _NON_EXECUTABLE_EXTS:
371+
continue
372+
if pattern in exe_file.name:
373+
if exe_file.suffix in ['.exe', '.out', '.app'] or (
374+
platform.system() != 'Windows' and os.access(exe_file, os.X_OK)):
286375
return exe_file
287376
except Exception as e:
288377
print(f"Warning: Could not find executable: {e}")
@@ -372,19 +461,16 @@ def create_comparison_plot(ref_data, test_data, test_name, output_dir,
372461
fig_cfg = LAYOUT['figure']
373462
fig = plt.figure(figsize=fig_cfg['figsize'], facecolor=fig_cfg['facecolor'])
374463

375-
# Extract model name from executable path, falling back to test file stem
376-
model_name = "Unknown Model"
464+
# Extract model name from executable path, falling back to test name
465+
model_name = test_name or "Unknown Model"
377466
if executable_path:
378467
exe_name = os.path.basename(executable_path)
379-
# Remove common executable extensions
380468
for ext in ['.exe', '.out', '.app']:
381469
if exe_name.endswith(ext):
382470
model_name = exe_name[:-len(ext)]
383471
break
384472
else:
385473
model_name = exe_name
386-
elif test_file_path:
387-
model_name = Path(test_file_path).stem
388474

389475
# Create Test Information panel
390476
info_content = (
@@ -485,8 +571,41 @@ def create_comparison_plot(ref_data, test_data, test_name, output_dir,
485571

486572
return n1, n2
487573

574+
def write_status_file(output_dir, test_name, status, metrics=None):
575+
"""Write a persistent status file for a comparison test.
576+
577+
These files survive across ctest runs, unlike LastTest.log which is
578+
overwritten each invocation. The report generator reads them as its
579+
primary source of pass/fail information.
580+
581+
Args:
582+
output_dir: Directory containing the test results (e.g. results/tests/rm3)
583+
test_name: Canonical test name (e.g. "rm3_decay")
584+
status: "PASS" or "FAIL"
585+
metrics: Optional dict with numeric metrics (l2_norm, linf_norm, …)
586+
"""
587+
import json
588+
status_dir = Path(output_dir)
589+
status_dir.mkdir(parents=True, exist_ok=True)
590+
status_file = status_dir / f"{test_name}.status.json"
591+
592+
payload = {
593+
"test_name": test_name,
594+
"status": status,
595+
"timestamp": datetime.now().isoformat(),
596+
}
597+
if metrics:
598+
payload["metrics"] = metrics
599+
600+
try:
601+
with open(status_file, 'w', encoding='utf-8') as f:
602+
json.dump(payload, f, indent=2)
603+
except Exception as e:
604+
print(f"Warning: could not write status file {status_file}: {e}")
605+
606+
488607
def run_comparison(ref_file, test_file, test_name=None, y_label="Value",
489-
executable_patterns=None, pass_criteria=None):
608+
executable_patterns=None, pass_criteria=None, status_name=None):
490609
"""
491610
Run a complete comparison between reference and test data
492611
@@ -575,11 +694,15 @@ def rel_to_root(path):
575694
# Check pass/fail criteria if provided
576695
if pass_criteria:
577696
l2_threshold, linf_threshold = pass_criteria
697+
metrics = {"l2_norm": n1, "linf_norm": n2}
698+
sname = status_name if status_name else test_name.lower().replace(' ', '_').replace('-', '_')
578699
if (n1 > l2_threshold or n2 > linf_threshold):
579700
print(f"TEST FAILED - L2 Norm: {n1:.2e}, L-infinity Norm: {n2:.2e}")
701+
write_status_file(test_file_path.parent, sname, "FAIL", metrics)
580702
return n1, n2, False
581703
else:
582704
print(f"TEST PASSED - L2 Norm: {n1:.2e}, L-infinity Norm: {n2:.2e}")
705+
write_status_file(test_file_path.parent, sname, "PASS", metrics)
583706
return n1, n2, True
584707

585708
return n1, n2
@@ -668,6 +791,7 @@ def rel_to_root(path):
668791

669792
# Check pass/fail criteria if provided
670793
passed = True
794+
metrics = {"l2_norm": n1, "linf_norm": n2}
671795
if validation_tolerance:
672796
l2_threshold, linf_threshold = validation_tolerance
673797
if (n1 > l2_threshold or n2 > linf_threshold):
@@ -676,6 +800,9 @@ def rel_to_root(path):
676800
else:
677801
print(f"TEST PASSED for {test_name} - L2 Norm: {n1:.2e}, L-infinity Norm: {n2:.2e}")
678802

803+
sname = config.get('status_name') or test_name.lower().replace(' ', '_').replace('-', '_')
804+
write_status_file(test_file_path.parent, sname,
805+
"PASS" if passed else "FAIL", metrics)
679806
results.append((n1, n2, passed))
680807

681808
except Exception as e:

0 commit comments

Comments
 (0)