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+
179228def 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+
256342def 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+
488607def 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