Skip to content

Commit ba41228

Browse files
Multi File Upload (#207)
* Slight rework on uploading. Fixes: multiple measurement or other files in PEtab problem. Previously: Silently ignored the other files * Small error in appending * correctly define optional and required files.
1 parent 8cc6bfd commit ba41228

4 files changed

Lines changed: 1005 additions & 36 deletions

File tree

src/petab_gui/controllers/mother_controller.py

Lines changed: 242 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,135 @@ def _open_file(self, actionable, file_path, sep, mode):
732732
file_path, mode, sep
733733
)
734734

735+
def _validate_yaml_structure(self, yaml_content):
736+
"""Validate PEtab YAML structure before attempting to load files.
737+
738+
Parameters
739+
----------
740+
yaml_content : dict
741+
The parsed YAML content.
742+
743+
Returns
744+
-------
745+
tuple
746+
(is_valid: bool, errors: list[str])
747+
"""
748+
errors = []
749+
750+
# Check format version
751+
if "format_version" not in yaml_content:
752+
errors.append("Missing 'format_version' field")
753+
754+
# Check problems array
755+
if "problems" not in yaml_content:
756+
errors.append("Missing 'problems' field")
757+
return False, errors
758+
759+
if (
760+
not isinstance(yaml_content["problems"], list)
761+
or not yaml_content["problems"]
762+
):
763+
errors.append("'problems' must be a non-empty list")
764+
return False, errors
765+
766+
problem = yaml_content["problems"][0]
767+
768+
# Optional but recommended fields
769+
if (
770+
"visualization_files" not in problem
771+
or not problem["visualization_files"]
772+
):
773+
errors.append("Warning: No visualization_files specified")
774+
775+
# Required fields in problem
776+
for field in [
777+
"sbml_files",
778+
"measurement_files",
779+
"observable_files",
780+
"condition_files",
781+
]:
782+
if field not in problem or not problem[field]:
783+
errors.append("Problem must contain at least one SBML file")
784+
785+
# Check parameter_file (at root level)
786+
if "parameter_file" not in yaml_content:
787+
errors.append("Missing 'parameter_file' at root level")
788+
789+
return len([e for e in errors if "Warning" not in e]) == 0, errors
790+
791+
def _validate_files_exist(self, yaml_dir, yaml_content):
792+
"""Validate that all files referenced in YAML exist.
793+
794+
Parameters
795+
----------
796+
yaml_dir : Path
797+
The directory containing the YAML file.
798+
yaml_content : dict
799+
The parsed YAML content.
800+
801+
Returns
802+
-------
803+
tuple
804+
(all_exist: bool, missing_files: list[str])
805+
"""
806+
missing_files = []
807+
problem = yaml_content["problems"][0]
808+
809+
# Check SBML files
810+
for sbml_file in problem.get("sbml_files", []):
811+
if not (yaml_dir / sbml_file).exists():
812+
missing_files.append(str(sbml_file))
813+
814+
# Check measurement files
815+
for meas_file in problem.get("measurement_files", []):
816+
if not (yaml_dir / meas_file).exists():
817+
missing_files.append(str(meas_file))
818+
819+
# Check observable files
820+
for obs_file in problem.get("observable_files", []):
821+
if not (yaml_dir / obs_file).exists():
822+
missing_files.append(str(obs_file))
823+
824+
# Check condition files
825+
for cond_file in problem.get("condition_files", []):
826+
if not (yaml_dir / cond_file).exists():
827+
missing_files.append(str(cond_file))
828+
829+
# Check parameter file
830+
if "parameter_file" in yaml_content:
831+
param_file = yaml_content["parameter_file"]
832+
if not (yaml_dir / param_file).exists():
833+
missing_files.append(str(param_file))
834+
835+
# Check visualization files (optional)
836+
for vis_file in problem.get("visualization_files", []):
837+
if not (yaml_dir / vis_file).exists():
838+
missing_files.append(str(vis_file))
839+
840+
return len(missing_files) == 0, missing_files
841+
842+
def _load_file_list(self, controller, file_list, file_type, yaml_dir):
843+
"""Load multiple files for a given controller.
844+
845+
Parameters
846+
----------
847+
controller : object
848+
The controller to load files into (e.g., measurement_controller).
849+
file_list : list[str]
850+
List of file names to load.
851+
file_type : str
852+
Human-readable file type for logging (e.g., "measurement").
853+
yaml_dir : Path
854+
The directory containing the YAML and data files.
855+
"""
856+
for i, file_name in enumerate(file_list):
857+
file_mode = "overwrite" if i == 0 else "append"
858+
controller.open_table(yaml_dir / file_name, mode=file_mode)
859+
self.logger.log_message(
860+
f"Loaded {file_type} file ({i + 1}/{len(file_list)}): {file_name}",
861+
color="blue",
862+
)
863+
735864
def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"):
736865
"""Open files from a YAML configuration.
737866
@@ -749,62 +878,149 @@ def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"):
749878
if controller == self.sbml_controller:
750879
continue
751880
controller.release_completers()
881+
752882
# Load the YAML content
753-
with open(yaml_path) as file:
883+
with open(yaml_path, encoding="utf-8") as file:
754884
yaml_content = yaml.safe_load(file)
755885

886+
# Validate PEtab version
756887
if (major := get_major_version(yaml_content)) != 1:
757888
raise ValueError(
758889
f"Only PEtab v1 problems are currently supported. "
759890
f"Detected version: {major}.x."
760891
)
761892

893+
# Validate YAML structure
894+
is_valid, errors = self._validate_yaml_structure(yaml_content)
895+
if not is_valid:
896+
error_msg = "Invalid YAML structure:\n - " + "\n - ".join(
897+
[e for e in errors if "Warning" not in e]
898+
)
899+
self.logger.log_message(error_msg, color="red")
900+
QMessageBox.critical(
901+
self.view, "Invalid PEtab YAML", error_msg
902+
)
903+
return
904+
905+
# Log warnings but continue
906+
warnings = [e for e in errors if "Warning" in e]
907+
for warning in warnings:
908+
self.logger.log_message(warning, color="orange")
909+
762910
# Resolve the directory of the YAML file to handle relative paths
763911
yaml_dir = Path(yaml_path).parent
764912

765-
# Upload SBML model
766-
sbml_file_path = (
767-
yaml_dir / yaml_content["problems"][0]["sbml_files"][0]
913+
# Validate file existence
914+
all_exist, missing_files = self._validate_files_exist(
915+
yaml_dir, yaml_content
768916
)
769-
self.sbml_controller.overwrite_sbml(sbml_file_path)
770-
self.measurement_controller.open_table(
771-
yaml_dir / yaml_content["problems"][0]["measurement_files"][0]
772-
)
773-
self.observable_controller.open_table(
774-
yaml_dir / yaml_content["problems"][0]["observable_files"][0]
775-
)
776-
self.parameter_controller.open_table(
777-
yaml_dir / yaml_content["parameter_file"]
778-
)
779-
self.condition_controller.open_table(
780-
yaml_dir / yaml_content["problems"][0]["condition_files"][0]
781-
)
782-
# Visualization is optional
783-
vis_path = yaml_content["problems"][0].get("visualization_files")
784-
if vis_path:
785-
self.visualization_controller.open_table(
786-
yaml_dir / vis_path[0]
917+
if not all_exist:
918+
error_msg = (
919+
"The following files referenced in the YAML are missing:\n - "
920+
+ "\n - ".join(missing_files)
921+
)
922+
self.logger.log_message(error_msg, color="red")
923+
QMessageBox.critical(self.view, "Missing Files", error_msg)
924+
return
925+
926+
problem = yaml_content["problems"][0]
927+
928+
# Load SBML model (required, single file)
929+
sbml_files = problem.get("sbml_files", [])
930+
if sbml_files:
931+
sbml_file_path = yaml_dir / sbml_files[0]
932+
self.sbml_controller.overwrite_sbml(sbml_file_path)
933+
self.logger.log_message(
934+
f"Loaded SBML file: {sbml_files[0]}", color="blue"
935+
)
936+
937+
# Load measurement files (multiple allowed)
938+
measurement_files = problem.get("measurement_files", [])
939+
if measurement_files:
940+
self._load_file_list(
941+
self.measurement_controller,
942+
measurement_files,
943+
"measurement",
944+
yaml_dir,
945+
)
946+
947+
# Load observable files (multiple allowed)
948+
observable_files = problem.get("observable_files", [])
949+
if observable_files:
950+
self._load_file_list(
951+
self.observable_controller,
952+
observable_files,
953+
"observable",
954+
yaml_dir,
955+
)
956+
957+
# Load condition files (multiple allowed)
958+
condition_files = problem.get("condition_files", [])
959+
if condition_files:
960+
self._load_file_list(
961+
self.condition_controller,
962+
condition_files,
963+
"condition",
964+
yaml_dir,
965+
)
966+
967+
# Load parameter file (required, single file at root level)
968+
if "parameter_file" in yaml_content:
969+
param_file = yaml_content["parameter_file"]
970+
self.parameter_controller.open_table(yaml_dir / param_file)
971+
self.logger.log_message(
972+
f"Loaded parameter file: {param_file}", color="blue"
973+
)
974+
975+
# Load visualization files (optional, multiple allowed)
976+
visualization_files = problem.get("visualization_files", [])
977+
if visualization_files:
978+
self._load_file_list(
979+
self.visualization_controller,
980+
visualization_files,
981+
"visualization",
982+
yaml_dir,
787983
)
788984
else:
789985
self.visualization_controller.clear_table()
986+
790987
# Simulation should be cleared
791988
self.simulation_controller.clear_table()
989+
792990
self.logger.log_message(
793991
"All files opened successfully from the YAML configuration.",
794992
color="green",
795993
)
796994
self.check_model()
797-
# rerun the completers
995+
996+
# Rerun the completers
798997
for controller in self.controllers:
799998
if controller == self.sbml_controller:
800999
continue
8011000
controller.setup_completers()
8021001
self.unsaved_changes_change(False)
8031002

1003+
except FileNotFoundError as e:
1004+
error_msg = f"File not found: {e.filename if hasattr(e, 'filename') else str(e)}"
1005+
self.logger.log_message(error_msg, color="red")
1006+
QMessageBox.warning(self.view, "File Not Found", error_msg)
1007+
except KeyError as e:
1008+
error_msg = f"Missing required field in YAML: {str(e)}"
1009+
self.logger.log_message(error_msg, color="red")
1010+
QMessageBox.warning(self.view, "Invalid YAML", error_msg)
1011+
except ValueError as e:
1012+
error_msg = f"Invalid YAML structure: {str(e)}"
1013+
self.logger.log_message(error_msg, color="red")
1014+
QMessageBox.warning(self.view, "Invalid YAML", error_msg)
1015+
except yaml.YAMLError as e:
1016+
error_msg = f"YAML parsing error: {str(e)}"
1017+
self.logger.log_message(error_msg, color="red")
1018+
QMessageBox.warning(self.view, "YAML Parsing Error", error_msg)
8041019
except Exception as e:
805-
self.logger.log_message(
806-
f"Failed to open files from YAML: {str(e)}", color="red"
807-
)
1020+
error_msg = f"Unexpected error loading YAML: {str(e)}"
1021+
self.logger.log_message(error_msg, color="red")
1022+
logging.exception("Full traceback for YAML loading error:")
1023+
QMessageBox.critical(self.view, "Error", error_msg)
8081024

8091025
def open_omex_and_load_files(self, omex_path=None):
8101026
"""Opens a petab problem from a COMBINE Archive."""

src/petab_gui/controllers/table_controllers.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,18 +216,34 @@ def append_df(self, new_df: pd.DataFrame):
216216
1. Columns are the union of both DataFrame columns.
217217
2. Rows are the union of both DataFrame rows (duplicates removed)
218218
"""
219+
self.proxy_model.setSourceModel(None)
219220
self.model.beginResetModel()
220-
combined_df = pd.concat([self.model.get_df(), new_df], axis=0)
221-
combined_df = combined_df[~combined_df.index.duplicated(keep="first")]
221+
current_df = self.model.get_df()
222+
223+
# For tables without a named index (measurement, visualization, simulation),
224+
# ignore the index to avoid removing appended data due to index conflicts
225+
if self.model.table_type in [
226+
"measurement",
227+
"visualization",
228+
"simulation",
229+
]:
230+
combined_df = pd.concat(
231+
[current_df, new_df], axis=0, ignore_index=True
232+
)
233+
else:
234+
# For tables with named indices, concatenate and remove duplicate indices
235+
combined_df = pd.concat([current_df, new_df], axis=0)
236+
combined_df = combined_df[
237+
~combined_df.index.duplicated(keep="first")
238+
]
239+
222240
self.model._data_frame = combined_df
223-
self.proxy_model.setSourceModel(None)
224-
self.proxy_model.setSourceModel(self.model)
225241
self.model.endResetModel()
226242
self.logger.log_message(
227243
f"Appended the {self.model.table_type} table with new data.",
228244
color="green",
229245
)
230-
# test: overwrite the new model as source model
246+
self.proxy_model.setSourceModel(self.model)
231247
self.overwritten_df.emit()
232248

233249
def clear_table(self):

0 commit comments

Comments
 (0)