From 710c0221e6d267ad4fa8adbbbd3902c5f67417e5 Mon Sep 17 00:00:00 2001 From: epernod Date: Tue, 20 Jan 2026 01:40:32 +0100 Subject: [PATCH 1/5] Add option to activate legacy mode to read old state files --- SofaRegressionProgram.py | 12 ++++++++++++ tools/RegressionSceneData.py | 24 ++++++++++++++++++++++++ tools/RegressionSceneList.py | 12 +++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/SofaRegressionProgram.py b/SofaRegressionProgram.py index e6f6c3e..60057a5 100644 --- a/SofaRegressionProgram.py +++ b/SofaRegressionProgram.py @@ -23,6 +23,7 @@ def __init__(self, input_folder, disable_progress_bar = False, verbose = False): self.scene_sets = [] # List self.disable_progress_bar = disable_progress_bar self.verbose = verbose + self.legacy_mode = False for root, dirs, files in os.walk(input_folder): for file in files: @@ -65,6 +66,7 @@ def write_all_sets_references(self): def compare_sets_references(self, id_set=0): scene_list = self.scene_sets[id_set] + scene_list.legacy_mode = self.legacy_mode nbr_scenes = scene_list.compare_all_references() return nbr_scenes @@ -128,6 +130,12 @@ def parse_args(): help='If set, will display more information', action='store_true' ) + parser.add_argument( + "--legacy-regression", + dest="legacy_mode", + help='If set, will read old format regression files', + action='store_true' + ) cmdline_args = parser.parse_args() @@ -146,6 +154,10 @@ def parse_args(): nbr_scenes = 0 + if args.legacy_mode: + print("Legacy regression mode activated.") + reg_prog.legacy_mode = True + replay = bool(args.replay) if replay: reg_prog.replay_references() diff --git a/tools/RegressionSceneData.py b/tools/RegressionSceneData.py index ea8fc56..c703baa 100644 --- a/tools/RegressionSceneData.py +++ b/tools/RegressionSceneData.py @@ -243,6 +243,30 @@ def compare_references(self): return True + + def compare_legacy_references(self): + pbar_simu = tqdm(total=float(self.steps), disable=self.disable_progress_bar) + pbar_simu.set_description("compare_legacy_references: " + self.file_scene_path) + + nbr_meca = len(self.meca_objs) + + # Reference data + ref_times = [] # shared timeline + ref_values = [] # List[List[np.ndarray]] + + self.total_error = [] + self.error_by_dof = [] + self.nbr_tested_frame = 0 + self.regression_failed = False + + # -------------------------------------------------- + # Load legacy reference files + # -------------------------------------------------- + + + return True + + def replayReferences(self): Sofa.Gui.GUIManager.Init("myscene", "qglviewer") Sofa.Gui.GUIManager.createGUI(self.root_node, __file__) diff --git a/tools/RegressionSceneList.py b/tools/RegressionSceneList.py index a2cff1d..41cf4a4 100644 --- a/tools/RegressionSceneList.py +++ b/tools/RegressionSceneList.py @@ -18,6 +18,7 @@ def __init__(self, file_path, disable_progress_bar = False, verbose = False): self.ref_dir_path = None self.disable_progress_bar = disable_progress_bar self.verbose = verbose + self.legacy_mode = False def get_nbr_scenes(self): @@ -29,6 +30,9 @@ def get_nbr_errors(self): def log_scenes_errors(self): for scene in self.scenes_data_sets: scene.log_errors() + + def set_legacy_mode(self, legacy_mode): + self.legacy_mode = legacy_mode def process_file(self): with open(self.file_path, 'r') as the_file: @@ -107,10 +111,16 @@ def compare_references(self, id_scene): self.nbr_errors = self.nbr_errors + 1 print(f'Error while trying to load: {str(e)}') else: - result = self.scenes_data_sets[id_scene].compare_references() + print(f'legacy_mode={self.legacy_mode}') + if self.legacy_mode: + result = self.scenes_data_sets[id_scene].compare_legacy_references() + else: + result = self.scenes_data_sets[id_scene].compare_references() + if not result: self.nbr_errors = self.nbr_errors + 1 + def compare_all_references(self): nbr_scenes = len(self.scenes_data_sets) pbar_scenes = tqdm(total=nbr_scenes, disable=self.disable_progress_bar) From d0ed0baa34f0642c58576d0cd4a920c3a92d41d8 Mon Sep 17 00:00:00 2001 From: epernod Date: Tue, 20 Jan 2026 02:08:19 +0100 Subject: [PATCH 2/5] Add reading of legacy files and compare them to current simulation. No code factorization yet --- tools/RegressionSceneData.py | 144 ++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/tools/RegressionSceneData.py b/tools/RegressionSceneData.py index c703baa..fa3c638 100644 --- a/tools/RegressionSceneData.py +++ b/tools/RegressionSceneData.py @@ -29,6 +29,62 @@ def default(self, obj): return obj.tolist() return JSONEncoder.default(self, obj) +# -------------------------------------------------- +# Helper: read the legacy state reference format +# -------------------------------------------------- +def read_legacy_reference(filename, mechanical_object): + ref_data = [] + times = [] + values = [] + + # Infer layout from MechanicalObject + n_points, dof_per_point = mechanical_object.position.value.shape + expected_size = n_points * dof_per_point + + + with gzip.open(filename, "rt") as f: + for line in f: + line = line.strip() + + if not line: + continue + + # Time marker + if line.startswith("T="): + current_time = float(line.split("=", 1)[1]) + times.append(current_time) + + # Positions + elif line.startswith("X="): + if current_time is None: + raise RuntimeError(f"X found before T in {filename}") + + raw = line.split("=", 1)[1].strip().split() + flat = np.asarray(raw, dtype=float) + + if flat.size != expected_size: + raise ValueError( + f"Legacy reference size mismatch in {filename}: " + f"expected {expected_size}, got {flat.size}" + ) + + values.append(flat.reshape((n_points, dof_per_point))) + + # Velocity (ignored) + elif line.startswith("V="): + continue + + if len(times) != len(values): + raise RuntimeError( + f"Legacy reference corrupted in {filename}: " + f"{len(times)} times vs {len(values)} X blocks" + ) + + return times, values + + + + class RegressionSceneData: def __init__(self, file_scene_path: str = None, file_ref_path: str = None, steps = 1000, @@ -262,7 +318,93 @@ def compare_legacy_references(self): # -------------------------------------------------- # Load legacy reference files # -------------------------------------------------- - + for meca_id in range(nbr_meca): + try: + times, values = read_legacy_reference(self.file_ref_path + ".reference_" + str(meca_id) + "_" + self.meca_objs[meca_id].name.value + "_mstate" + ".txt.gz", + self.meca_objs[meca_id]) + except FileNotFoundError as e: + print(f"Error while reading legacy references: {str(e)}") + return False + + # Keep timeline from first MechanicalObject + if meca_id == 0: + ref_times = times + else: + if len(times) != len(ref_times): + print( + f"Reference timeline mismatch for file {self.file_scene_path}, " + f"MechanicalObject {meca_id}" + ) + return False + + ref_values.append(values) + self.total_error.append(0.0) + self.error_by_dof.append(0.0) + + if self.verbose: + print(f"ref_times len: {len(ref_times)}\n") + print(f"ref_values[0] len: {len(ref_values[0])}\n") + print(f"ref_values[0][0] shape: {ref_values[0][0].shape}\n") + + # -------------------------------------------------- + # Simulation + comparison + # -------------------------------------------------- + + frame_step = 0 + nbr_frames = len(ref_times) + dt = self.root_node.dt.value + + for step in range(0, self.steps + 1): + simu_time = dt * step + + # Use tolerance for float comparison + if frame_step < nbr_frames and np.isclose(simu_time, ref_times[frame_step]): + for meca_id in range(nbr_meca): + meca_dofs = np.copy(self.meca_objs[meca_id].position.value) + data_ref = ref_values[meca_id][frame_step] + + if meca_dofs.shape != data_ref.shape: + print( + f"Shape mismatch for file {self.file_scene_path}, " + f"MechanicalObject {meca_id}: " + f"reference {data_ref.shape} vs current {meca_dofs.shape}" + ) + return False + + data_diff = data_ref - meca_dofs + + # Compute total distance between the 2 sets + full_dist = np.linalg.norm(data_diff) + error_by_dof = full_dist / float(data_diff.size) + + if self.verbose: + print( + f"{step} | {self.meca_objs[meca_id].name.value} | " + f"full_dist: {full_dist} | " + f"error_by_dof: {error_by_dof} | " + f"nbrDofs: {data_ref.size}" + ) + + self.total_error[meca_id] += full_dist + self.error_by_dof[meca_id] += error_by_dof + + frame_step += 1 + self.nbr_tested_frame += 1 + + # security exit if simulation steps exceed nbr_frames + if frame_step == nbr_frames: + break + + Sofa.Simulation.animate(self.root_node, dt) + + pbar_simu.update(1) + pbar_simu.close() + + # Final regression returns value + for meca_id in range(nbr_meca): + if self.total_error[meca_id] > self.epsilon: + self.regression_failed = True + return False return True From 7904fd1684d9dcbf4f05cd56ecc353228fd6f445 Mon Sep 17 00:00:00 2001 From: epernod Date: Tue, 20 Jan 2026 12:03:59 +0100 Subject: [PATCH 3/5] [scripts] Fix support of REGRESSION_DIR env variable --- tools/RegressionSceneList.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/RegressionSceneList.py b/tools/RegressionSceneList.py index 41cf4a4..6704bf3 100644 --- a/tools/RegressionSceneList.py +++ b/tools/RegressionSceneList.py @@ -49,8 +49,15 @@ def process_file(self): continue if count == 0: - self.ref_dir_path = os.path.join(self.file_dir, values[0]) - self.ref_dir_path = os.path.abspath(self.ref_dir_path) + if ("$REGRESSION_DIR" in values[0]): # using environment variable + if ("REGRESSION_DIR" in os.environ): + self.ref_dir_path = values[0].replace("$REGRESSION_DIR", os.environ["REGRESSION_DIR"]) + else: + print(f"Error while processing $REGRESSION_DIR: Environment variable REGRESSION_DIR is not set.") + return + else: # direct abosulte or relative path + self.ref_dir_path = os.path.join(self.file_dir, values[0]) + self.ref_dir_path = os.path.abspath(self.ref_dir_path) if not os.path.isdir(self.ref_dir_path): print(f'Error: Reference directory mentioned by file \'{self.file_path}\' does not exist: {self.ref_dir_path}') From c8bea760182c34945deecd934f8b596fae47306e Mon Sep 17 00:00:00 2001 From: epernod Date: Tue, 20 Jan 2026 16:00:23 +0100 Subject: [PATCH 4/5] Fix some error catch and update some logs --- tools/RegressionSceneData.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tools/RegressionSceneData.py b/tools/RegressionSceneData.py index fa3c638..c04ea35 100644 --- a/tools/RegressionSceneData.py +++ b/tools/RegressionSceneData.py @@ -65,7 +65,7 @@ def read_legacy_reference(filename, mechanical_object): if flat.size != expected_size: raise ValueError( f"Legacy reference size mismatch in {filename}: " - f"expected {expected_size}, got {flat.size}" + f"expected {expected_size}, got {flat.size}\n" ) values.append(flat.reshape((n_points, dof_per_point))) @@ -129,6 +129,8 @@ def log_errors(self): print("### Failed: " + self.file_scene_path) print(" ### Total Error per MechanicalObject: " + str(self.total_error)) print(" ### Error by Dofs: " + str(self.error_by_dof)) + elif self.nbr_tested_frame == 0: + print("### Failed: No frames were tested for " + self.file_scene_path) else: print ("### Success: " + self.file_scene_path + " | Number of key frames compared without error: " + str(self.nbr_tested_frame)) @@ -322,8 +324,11 @@ def compare_legacy_references(self): try: times, values = read_legacy_reference(self.file_ref_path + ".reference_" + str(meca_id) + "_" + self.meca_objs[meca_id].name.value + "_mstate" + ".txt.gz", self.meca_objs[meca_id]) - except FileNotFoundError as e: - print(f"Error while reading legacy references: {str(e)}") + except Exception as e: + print( + f"Error while reading legacy references for MechanicalObject " + f"{self.meca_objs[meca_id].name.value}: {str(e)}" + ) return False # Keep timeline from first MechanicalObject From fcb736a9fa6449f9319caa55543a2d231fce2e07 Mon Sep 17 00:00:00 2001 From: epernod Date: Wed, 28 Jan 2026 23:45:30 +0100 Subject: [PATCH 5/5] Add option to test if mechanicalObject is mapped and used it according to requested option --- tools/RegressionSceneData.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tools/RegressionSceneData.py b/tools/RegressionSceneData.py index c04ea35..4627223 100644 --- a/tools/RegressionSceneData.py +++ b/tools/RegressionSceneData.py @@ -29,6 +29,17 @@ def default(self, obj): return obj.tolist() return JSONEncoder.default(self, obj) + +def is_mapped(node): + mapping = node.getMechanicalMapping() + + if mapping is None: + return False + else: + return True + # no mapping in this node context + + # -------------------------------------------------- # Helper: read the legacy state reference format # -------------------------------------------------- @@ -147,10 +158,11 @@ def print_meca_objs(self): def parse_node(self, node, level = 0): for child in node.children: mstate = child.getMechanicalState() - if mstate: - if is_simulated(child): + if mstate and is_simulated(child): + + if self.meca_in_mapping is True or not is_mapped(child): self.meca_objs.append(mstate) - + self.parse_node(child, level + 1)