From 5ab538a2eb599a73b43ee53eea17d036d17e28f6 Mon Sep 17 00:00:00 2001 From: Paul Baksic Date: Thu, 17 Sep 2026 15:51:08 +0200 Subject: [PATCH 1/7] Add cli option to select type of regression + prepare list to know which type of regression it is --- .../SofaRegressionProgram.py | 23 +++++++++++++++---- .../tools/RegressionSceneList.py | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/SofaRegressionProgram/SofaRegressionProgram.py b/SofaRegressionProgram/SofaRegressionProgram.py index 58d960d..e9de3e5 100644 --- a/SofaRegressionProgram/SofaRegressionProgram.py +++ b/SofaRegressionProgram/SofaRegressionProgram.py @@ -16,12 +16,12 @@ import SofaRuntime # importing SofaRuntime will add the py3 loader to the scene loaders import tools.RegressionSceneList as RegressionSceneList import tools.RegressionWorker as RegressionWorker -from tools.RegressionHelper import writeMessage +from tools.RegressionHelper import writeMessage, writeWarning regression_file_extension = ".regression-tests" class RegressionProgram: - def __init__(self, input_folders, filter = None, disable_progress_bar = False, verbose = 1, nbr_jobs = 1, logs_output = None): + def __init__(self, input_folders, filter = None, reg_type = 'ALL', disable_progress_bar = False, verbose = 1, nbr_jobs = 1, logs_output = None): """Initialize the RegressionProgram Args: @@ -46,9 +46,10 @@ def __init__(self, input_folders, filter = None, disable_progress_bar = False, v for directory in input_folders : for root, dirs, files in os.walk(directory): for file in files: - if file.endswith(regression_file_extension): - file_path = os.path.join(root, file) + file_path = os.path.join(root, file) + #Warning lazy or in the end, if not lazy then this breaks + if file.endswith(regression_file_extension) and (reg_type == 'ALL' or RegressionSceneList.RegressionSceneList.RegressionType[reg_type].value in file) : scene_list = RegressionSceneList.RegressionSceneList(file_path, filter, self.disable_progress_bar, verbose, self.nbr_jobs) if err_logs_stream is not None: @@ -60,6 +61,9 @@ def __init__(self, input_folders, filter = None, disable_progress_bar = False, v print("", file=err_logs_stream) self.scene_sets.append(scene_list) + elif file.endswith(regression_file_extension): + writeWarning(f"Regression file {file_path} skipped because of selected regression type {reg_type}", self.verbose) + finally: if self.logs_output is not None : with open(Path(self.logs_output) / "parse_errors_logs.txt", 'w', encoding="utf-8") as summary_file: @@ -140,6 +144,15 @@ def make_parser(): default=[], type=str) + regression_type_choices = ['ALL', *(reg_type.name for reg_type in RegressionSceneList.RegressionSceneList.RegressionType)] + parser.add_argument('--regression-type', + dest='reg_type', + choices=regression_type_choices, + help=f"The regression type from {regression_type_choices}. Default value is ALL.", + type=str, + default='ALL') + + parser.add_argument('--filter', dest='filter', help="A regex filter to select scenes to test (e.g., '^demo.*.scn$')", @@ -222,7 +235,7 @@ def make_parser(): # 2- Process file if args.input: - reg_prog = RegressionProgram(args.input, args.filter, args.progress_bar_is_disabled, verbose, args.jobs, logs_output = args.output) + reg_prog = RegressionProgram(args.input, args.filter, args.reg_type, args.progress_bar_is_disabled, verbose, args.jobs, logs_output = args.output) else: parser.print_help() exit("Error: Argument is required ! Quitting.") diff --git a/SofaRegressionProgram/tools/RegressionSceneList.py b/SofaRegressionProgram/tools/RegressionSceneList.py index 7b6a3f7..189b8e6 100644 --- a/SofaRegressionProgram/tools/RegressionSceneList.py +++ b/SofaRegressionProgram/tools/RegressionSceneList.py @@ -1,21 +1,39 @@ +from enum import Enum import os import math import tools.RegressionSceneData as RegressionSceneData import tools.RegressionHelper as helper import tools.RegressionWorker as RegressionWorker import sys +from pathlib import Path import re ## This class is responsible for loading a file.regression-tests to gather the list of scene to test with all arguments ## It will provide the API to launch the tests or write refs on all scenes contained in this file class RegressionSceneList: + + class RegressionType(Enum): + STATE = "RegressionStateScenes" + TOPOLOGY = "RegressionTopologyScenes" + + def __init__(self, file_path, filter, disable_progress_bar = False, verbose = 1, nbr_jobs = 1): """ /// Path to the file.regression-tests containing the list of scene to tests with all arguments std::string filePath; """ self.file_path = file_path + + self.regression_type = None + for reg_type in RegressionSceneList.RegressionType: + if reg_type.value in Path(self.file_path).name: + self.regression_type = reg_type + break + + if self.regression_type is None: + raise ValueError(f"Regression type {Path(self.file_path).name} not recognize, can be of types {[reg_type.value for reg_type in RegressionSceneList.RegressionType]}.") + self.filter = filter self.file_dir = os.path.dirname(file_path) self.scenes_data_sets = [] # List From 23b020075931f0dd923f88713702e91a80925f8a Mon Sep 17 00:00:00 2001 From: Paul Baksic Date: Thu, 17 Sep 2026 16:42:46 +0200 Subject: [PATCH 2/7] Started work on adding topology test, need a simple factory for sceneData --- .../tools/RegressionSceneData.py | 37 ++++++++++++++++++- .../tools/RegressionSceneList.py | 3 ++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/SofaRegressionProgram/tools/RegressionSceneData.py b/SofaRegressionProgram/tools/RegressionSceneData.py index 4095a6e..af80b9c 100644 --- a/SofaRegressionProgram/tools/RegressionSceneData.py +++ b/SofaRegressionProgram/tools/RegressionSceneData.py @@ -80,12 +80,10 @@ def __init__(self, file_scene_path: str = None, file_ref_path: str = None, steps self.epsilon = float(epsilon) self.meca_in_mapping = bool(meca_in_mapping) self.dump_number_step = int(dump_number_step) - self.meca_objs = [] self.filenames = [] self.mins = [] self.maxs = [] self.total_error = [] - self.error_by_dof = [] self.nbr_tested_frame = 0 self.regression_failed = False self.root_node = None @@ -97,6 +95,39 @@ def print_info(self): helper.writeLog("Test scene: " + self.file_scene_path + " vs " + self.file_ref_path + " using: " + str(self.steps) + " " + str(self.epsilon), self.verbose) + def log_errors(self, log_prefix='', err_log_stream=None): + pass + + def apply_worker_result(self, result): + pass + + def parse_node(self, node, level = 0): + pass + + def load_scene(self, format = "JSON"): + pass + + def write_references(self, format = "JSON"): + pass + + def compare_references(self, format = "JSON"): + pass + + def compare_legacy_references(self): + pass + + def is_replay_available(self): + return False + +class StateRegressionSceneData(RegressionSceneData): + def __init__(self, file_scene_path: str = None, file_ref_path: str = None, steps = 1000, + epsilon = 0.0001, meca_in_mapping = True, dump_number_step = 1, disable_progress_bar = False, verbose = 1): + + RegressionSceneData.__init__(self, file_scene_path, file_ref_path, steps, epsilon, meca_in_mapping, dump_number_step, disable_progress_bar, verbose) + + self.meca_objs = [] + self.error_by_dof = [] + def log_errors(self, log_prefix='', err_log_stream=None): if self.regression_failed: helper.writeError( @@ -541,6 +572,8 @@ def compare_legacy_references(self): return True + def is_replay_available(self): + return True def replay_references(self): diff --git a/SofaRegressionProgram/tools/RegressionSceneList.py b/SofaRegressionProgram/tools/RegressionSceneList.py index 189b8e6..a522a86 100644 --- a/SofaRegressionProgram/tools/RegressionSceneList.py +++ b/SofaRegressionProgram/tools/RegressionSceneList.py @@ -319,6 +319,9 @@ def compare_references(self, id_scene): def compare_all_references(self): return self._run_tasks("compare", "Compare all scenes from: " + self.file_path) + def is_replay_available(self): + #TODO check if sceneData type is able to do it + return True def replay_references(self, id_scene): if (id_scene < 0 or id_scene >= len(self.scenes_data_sets)): From 324cfe8caf80ec1f95176fd6bebaabfff2716be6 Mon Sep 17 00:00:00 2001 From: Paul Baksic Date: Fri, 18 Sep 2026 10:47:19 +0200 Subject: [PATCH 3/7] make the worker agnostic from regression type --- .../SofaRegressionProgram.py | 10 +++++-- .../tools/RegressionSceneData.py | 28 +++++++++++++++++-- .../tools/RegressionSceneList.py | 15 +++++----- .../tools/RegressionWorker.py | 27 ++++++++++++------ 4 files changed, 58 insertions(+), 22 deletions(-) diff --git a/SofaRegressionProgram/SofaRegressionProgram.py b/SofaRegressionProgram/SofaRegressionProgram.py index e9de3e5..a9b470d 100644 --- a/SofaRegressionProgram/SofaRegressionProgram.py +++ b/SofaRegressionProgram/SofaRegressionProgram.py @@ -37,6 +37,7 @@ def __init__(self, input_folders, filter = None, reg_type = 'ALL', disable_prog self.legacy_mode = False self.nbr_jobs = RegressionWorker.resolve_nbr_jobs(nbr_jobs) self.logs_output = logs_output + self.reg_type = reg_type err_logs_stream = None if self.logs_output is not None : @@ -49,7 +50,7 @@ def __init__(self, input_folders, filter = None, reg_type = 'ALL', disable_prog file_path = os.path.join(root, file) #Warning lazy or in the end, if not lazy then this breaks - if file.endswith(regression_file_extension) and (reg_type == 'ALL' or RegressionSceneList.RegressionSceneList.RegressionType[reg_type].value in file) : + if file.endswith(regression_file_extension) and (reg_type == 'ALL' or RegressionSceneList.RegressionSceneList.RegressionType[reg_type].value[0] in file) : scene_list = RegressionSceneList.RegressionSceneList(file_path, filter, self.disable_progress_bar, verbose, self.nbr_jobs) if err_logs_stream is not None: @@ -124,8 +125,11 @@ def compare_all_sets_references(self): return self.run_all_sets("compare", "Compare All sets") def replay_references(self, id_scene, id_set=0): - scene_list = self.scene_sets[id_set] - scene_list.replay_references(id_scene) + if(self.scene_sets[id_set].regression_type is not None and self.scene_sets[id_set].regression_type.value[1].is_replay_available()): + scene_list = self.scene_sets[id_set] + scene_list.replay_references(id_scene) + else: + raise ValueError(f"Replay is not available for regression type {self.scene_sets[id_set].regression_type}") diff --git a/SofaRegressionProgram/tools/RegressionSceneData.py b/SofaRegressionProgram/tools/RegressionSceneData.py index af80b9c..093094b 100644 --- a/SofaRegressionProgram/tools/RegressionSceneData.py +++ b/SofaRegressionProgram/tools/RegressionSceneData.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass import time import numpy as np import pathlib @@ -116,8 +117,6 @@ def compare_references(self, format = "JSON"): def compare_legacy_references(self): pass - def is_replay_available(self): - return False class StateRegressionSceneData(RegressionSceneData): def __init__(self, file_scene_path: str = None, file_ref_path: str = None, steps = 1000, @@ -572,7 +571,8 @@ def compare_legacy_references(self): return True - def is_replay_available(self): + @staticmethod + def is_replay_available(): return True def replay_references(self): @@ -585,3 +585,25 @@ def replay_references(self): Sofa.Gui.GUIManager.SetDimension(1920, 1080) Sofa.Gui.GUIManager.MainLoop(self.root_node) Sofa.Gui.GUIManager.closeGUI() + +class TopologyRegressionSceneData(RegressionSceneData): + + @dataclass + class TopologyError(): + edges_error : list[float] + triangle_error : list[float] + quad_error : list[float] + tetra_error : list[float] + hexa_error : list[float] + + def __init__(self, file_scene_path: str = None, file_ref_path: str = None, steps = 1000, + epsilon = 0.0001, meca_in_mapping = True, dump_number_step = 1, disable_progress_bar = False, verbose = 1): + + RegressionSceneData.__init__(self, file_scene_path, file_ref_path, steps, epsilon, meca_in_mapping, dump_number_step, disable_progress_bar, verbose) + + self.topology = [] + self.error_topology = TopologyRegressionSceneData.TopologyError + + @staticmethod + def is_replay_available(): + return False diff --git a/SofaRegressionProgram/tools/RegressionSceneList.py b/SofaRegressionProgram/tools/RegressionSceneList.py index a522a86..3fdebb5 100644 --- a/SofaRegressionProgram/tools/RegressionSceneList.py +++ b/SofaRegressionProgram/tools/RegressionSceneList.py @@ -14,8 +14,8 @@ class RegressionSceneList: class RegressionType(Enum): - STATE = "RegressionStateScenes" - TOPOLOGY = "RegressionTopologyScenes" + STATE = ("RegressionStateScenes", RegressionSceneData.StateRegressionSceneData) + TOPOLOGY = ("RegressionTopologyScenes", RegressionSceneData.TopologyRegressionSceneData) def __init__(self, file_path, filter, disable_progress_bar = False, verbose = 1, nbr_jobs = 1): @@ -27,12 +27,12 @@ def __init__(self, file_path, filter, disable_progress_bar = False, verbose = 1, self.regression_type = None for reg_type in RegressionSceneList.RegressionType: - if reg_type.value in Path(self.file_path).name: + if reg_type.value[0] in Path(self.file_path).name: self.regression_type = reg_type break if self.regression_type is None: - raise ValueError(f"Regression type {Path(self.file_path).name} not recognize, can be of types {[reg_type.value for reg_type in RegressionSceneList.RegressionType]}.") + raise ValueError(f"Regression type {Path(self.file_path).name} not recognize, can be of types {[reg_type.value[0] for reg_type in RegressionSceneList.RegressionType]}.") self.filter = filter self.file_dir = os.path.dirname(file_path) @@ -173,7 +173,7 @@ def parse_scene_line(self, values, line_number, err_log_stream = None): full_ref_file_path = os.path.normpath(os.path.join(self.ref_dir_path, values[0])) - return RegressionSceneData.RegressionSceneData(full_file_path, full_ref_file_path, + return self.regression_type.value[1](full_file_path, full_ref_file_path, steps, epsilon, meca_in_mapping, dump_number_step, self.disable_progress_bar, self.verbose) @@ -320,8 +320,9 @@ def compare_all_references(self): return self._run_tasks("compare", "Compare all scenes from: " + self.file_path) def is_replay_available(self): - #TODO check if sceneData type is able to do it - return True + if self.regression_type is not None: + return self.regression_type.value[1].is_replay_available() + return False def replay_references(self, id_scene): if (id_scene < 0 or id_scene >= len(self.scenes_data_sets)): diff --git a/SofaRegressionProgram/tools/RegressionWorker.py b/SofaRegressionProgram/tools/RegressionWorker.py index 1aa1b6d..cb6e964 100644 --- a/SofaRegressionProgram/tools/RegressionWorker.py +++ b/SofaRegressionProgram/tools/RegressionWorker.py @@ -53,7 +53,7 @@ def _safe_remove(path): # -------------------------------------------------- # Parent side: spawn one child process for one scene # -------------------------------------------------- -def run_scene_in_subprocess(scene_data, mode, legacy=False, +def run_scene_in_subprocess(scene_data, mode, scene_list = None, legacy=False, disable_progress_bar=False, verbose=1, format="JSON", python_exe=None, capture_output=False): @@ -92,6 +92,7 @@ def run_scene_in_subprocess(scene_data, mode, legacy=False, "--ref", str(scene_data.file_ref_path), "--steps", str(scene_data.steps), "--epsilon", repr(scene_data.epsilon), + "--regression-type", scene_list.regression_type.name, "--meca-in-mapping", "1" if scene_data.meca_in_mapping else "0", "--dump-number-step", str(scene_data.dump_number_step), "--format", format, @@ -198,6 +199,7 @@ def _run(task): task["scene_data"], mode=task["mode"], legacy=task.get("legacy", False), + scene_list= task["scene_list"], # In parallel the per-step progress bars of the children are # captured along with their output: they would only produce noise. disable_progress_bar=disable_progress_bar or nbr_jobs > 1, @@ -260,8 +262,9 @@ def _run(task): executor.shutdown(wait=False, cancel_futures=True) raise finally: - with open(Path(logs_output) / "run_errors_logs.txt", 'w', encoding="utf-8") as error_logs_file: - error_logs_file.write(stream_out.getvalue()) + if logs_output is not None: + with open(Path(logs_output) / "run_errors_logs.txt", 'w', encoding="utf-8") as error_logs_file: + error_logs_file.write(stream_out.getvalue()) return len(tasks) @@ -276,6 +279,7 @@ def _make_worker_parser(): parser.add_argument("--ref", required=True) parser.add_argument("--steps", type=int, required=True) parser.add_argument("--epsilon", type=float, required=True) + parser.add_argument("--regression-type", dest="regression_type", type=str, required=True) parser.add_argument("--meca-in-mapping", dest="meca_in_mapping", choices=["0", "1"], required=True) parser.add_argument("--dump-number-step", dest="dump_number_step", type=int, required=True) parser.add_argument("--format", default="JSON") @@ -307,8 +311,13 @@ def _worker_main(): import Sofa import SofaRuntime # noqa: F401 (registers the py3 scene loader) import tools.RegressionSceneData as RegressionSceneData + import tools.RegressionSceneList as RegressionSceneList + import numpy as np - scene = RegressionSceneData.RegressionSceneData( + np.set_printoptions(legacy='1.25') # revert printing floating-point type in numpy (concretely remove np.array when displaying a list of np.float) + + + scene = RegressionSceneList.RegressionSceneList.RegressionType[args.regression_type].value[1]( file_scene_path=args.scene, file_ref_path=args.ref, steps=args.steps, @@ -333,13 +342,13 @@ def _worker_main(): result = { "ok": True, "result": bool(passed), - "regression_failed": bool(scene.regression_failed), - "nbr_tested_frame": int(scene.nbr_tested_frame), - "total_run_time": int(scene.total_run_time), - "error_by_dof": [float(v) for v in scene.error_by_dof], - "total_error": [float(v) for v in scene.total_error], "error": None, + **scene.__dict__ } + ## Remove data that break json + result.__delitem__("meca_objs") + result.__delitem__("root_node") + except Exception as e: import traceback result = {"ok": False, "error": str(e), "traceback": traceback.format_exc()} From 4f3818914772a5b31312d187fb0aa1f80b3bcc1e Mon Sep 17 00:00:00 2001 From: Paul Baksic Date: Fri, 18 Sep 2026 17:08:25 +0200 Subject: [PATCH 4/7] Add legacy and new reference files version --- .../tools/ReferenceFileIO.py | 71 ++++ .../tools/RegressionSceneData.py | 383 +++++++++++++++++- .../tools/RegressionWorker.py | 8 +- 3 files changed, 458 insertions(+), 4 deletions(-) diff --git a/SofaRegressionProgram/tools/ReferenceFileIO.py b/SofaRegressionProgram/tools/ReferenceFileIO.py index cd014e8..77cfe0c 100644 --- a/SofaRegressionProgram/tools/ReferenceFileIO.py +++ b/SofaRegressionProgram/tools/ReferenceFileIO.py @@ -137,3 +137,74 @@ def read_legacy_reference(filename, mechanical_object): ) return times, values + +# -------------------------------------------------- +# Helper: read the legacy topology reference format +# -------------------------------------------------- +# Written by the former C++ WriteTopology component, one block per timestep: +# T=