From 46e7a36b1d0a3305274971ec8b0711db356bfd27 Mon Sep 17 00:00:00 2001 From: Pavlos Stephanos Bekiaris <36934614+Paulocracy@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:04:05 +0200 Subject: [PATCH] Small fixes (#484) * Fix errors when loading strain designs * New FBA error message with community editions * Better handling of community edition errors * Add XLSX generation with all in and out fluxes * Fix missing newline character in net conversion * Refactoring; More community errors; Qt fix * More to core_gui * Remove special Gurobi handling; Point size fix * Better language * Remove edge error case; Import fix * simplification * Fix RGB color function argument typing --------- Co-authored-by: axelvonkamp --- cnapy/appdata.py | 4 +- cnapy/application.py | 2 +- cnapy/core.py | 16 -- cnapy/core_gui.py | 37 ++++ cnapy/gui_elements/main_window.py | 232 ++++++++++++++++---- cnapy/gui_elements/mcs_dialog.py | 18 +- cnapy/gui_elements/reactions_list.py | 3 +- cnapy/gui_elements/strain_design_dialog.py | 10 +- cnapy/gui_elements/thermodynamics_dialog.py | 2 +- 9 files changed, 244 insertions(+), 80 deletions(-) create mode 100644 cnapy/core_gui.py diff --git a/cnapy/appdata.py b/cnapy/appdata.py index 4454450d..43b5667c 100644 --- a/cnapy/appdata.py +++ b/cnapy/appdata.py @@ -189,13 +189,13 @@ def compute_color_heat(self, value: Tuple[float, float], low, high): h = 255 else: h = mean * 255 / high - return QColor.fromRgb(255-h, 255, 255 - h) + return QColor.fromRgbF(255-h, 255, 255 - h) else: if low == 0.0: h = 255 else: h = mean * 255 / low - return QColor.fromRgb(255, 255 - h, 255 - h) + return QColor.fromRgbF(255, 255 - h, 255 - h) def low_and_high(self) -> Tuple[int, int]: low = 0 diff --git a/cnapy/application.py b/cnapy/application.py index d4d4d80e..038d80c6 100644 --- a/cnapy/application.py +++ b/cnapy/application.py @@ -71,7 +71,7 @@ def __init__(self): self.qapp.setStyle("fusion") config_file_version = self.read_config() font = self.qapp.font() - font.setPointSize(self.appdata.font_size) + font.setPointSizeF(self.appdata.font_size) self.qapp.setFont(font) self.window = MainWindow(self.appdata) self.appdata.window = self.window diff --git a/cnapy/core.py b/cnapy/core.py index 1c9de385..d3c47156 100644 --- a/cnapy/core.py +++ b/cnapy/core.py @@ -4,13 +4,11 @@ from collections import defaultdict from typing import Dict, Tuple, List from collections import Counter -import gurobipy import numpy import cobra from cobra.util.array import create_stoichiometric_matrix from cobra.core.dictlist import DictList from optlang.symbolics import Zero, Add -from qtpy.QtWidgets import QMessageBox import efmtool_link.efmtool4cobra as efmtool4cobra import efmtool_link.efmtool_extern as efmtool_extern @@ -369,17 +367,3 @@ def replace_ids(dict_list: DictList, annotation_key: str, unambiguous_only: bool pass if len(candidates) > 0 and old_id == entry.id: print("Could not find a new ID for", entry.id, "in", candidates) - -# TODO: should not be in the core module -def model_optimization_with_exceptions(model: cobra.Model): - try: - return model.optimize() - except gurobipy.GurobiError as error: - msgBox = QMessageBox() - msgBox.setWindowTitle("Gurobi Error!") - msgBox.setText("Calculation failed due to the following Gurobi solver error " +\ - "(if this error cannot be resolved,\ntry using a different solver by changing " +\ - "it under 'Config->Configure cobrapy'):\n"+error.message+\ - "\nNOTE: Another error message will follow, you can safely ignore it.") - msgBox.setIcon(QMessageBox.Warning) - msgBox.show() diff --git a/cnapy/core_gui.py b/cnapy/core_gui.py new file mode 100644 index 00000000..af87cd0c --- /dev/null +++ b/cnapy/core_gui.py @@ -0,0 +1,37 @@ +import gurobipy +import io +import traceback +import cobra +from qtpy.QtWidgets import QMessageBox + + +def except_likely_community_model_error() -> None: + """Shows a message in the case that using a (size-limited) community edition solver version probably caused an error.""" + community_error_text = "Solver error. One possible reason: You set CPLEX or Gurobi as solver although you only use their\n"+\ + "Community edition which only work for small models. To solve this, either follow the instructions under\n"+\ + "'Config->Configure IBM CPLEX full version' or 'Config->Configure Gurobi full version', or use a different solver such as GLPK." + msgBox = QMessageBox() + msgBox.setWindowTitle("Error") + msgBox.setText(community_error_text) + msgBox.setIcon(QMessageBox.Warning) + msgBox.exec() + + +def get_last_exception_string() -> str: + output = io.StringIO() + traceback.print_exc(file=output) + return output.getvalue() + + +def has_community_error_substring(string: str) -> bool: + return ("Model too large for size-limited license" in string) or ("1016: Community Edition" in string) + + +def model_optimization_with_exceptions(model: cobra.Model) -> None: + try: + return model.optimize() + except Exception: + exstr = get_last_exception_string() + # Check for substrings of Gurobi and CPLEX community edition errors + if has_community_error_substring(exstr): + except_likely_community_model_error() diff --git a/cnapy/gui_elements/main_window.py b/cnapy/gui_elements/main_window.py index 5857c511..89dd3c5c 100644 --- a/cnapy/gui_elements/main_window.py +++ b/cnapy/gui_elements/main_window.py @@ -7,7 +7,7 @@ import pickle import xml.etree.ElementTree as ET from cnapy.flux_vector_container import FluxVectorContainer -from cnapy.core import model_optimization_with_exceptions +from cnapy.core_gui import model_optimization_with_exceptions, except_likely_community_model_error, get_last_exception_string, has_community_error_substring import cobra from optlang_enumerator.cobra_cnapy import CNApyModel from optlang_enumerator.mcs_computation import flux_variability_analysis @@ -454,11 +454,17 @@ def __init__(self, appdata: AppData): net_conversion_action.triggered.connect( self.show_net_conversion) + all_in_out_fluxes_action = QAction( + "Export all in/out fluxes as an XLSX table...", self) + all_in_out_fluxes_action.triggered.connect(self.all_in_out_fluxes) + self.analysis_menu.addAction(all_in_out_fluxes_action) + in_out_flux_action = QAction( - "Compute in/out fluxes at metabolite...", self) + "Compute in/out fluxes at single metabolite...", self) in_out_flux_action.triggered.connect(self.in_out_flux) self.analysis_menu.addAction(in_out_flux_action) + self.config_menu = self.menu.addMenu("Config") config_action = QAction("Configure CNApy...", self) @@ -757,9 +763,7 @@ def export_sbml(self): try: self.save_sbml(filename) except ValueError: - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() + exstr = get_last_exception_string() utils.show_unknown_error_box(exstr) self.setCursor(Qt.ArrowCursor) @@ -1196,9 +1200,7 @@ def new_project_from_sbml(self): try: cobra_py_model = CNApyModel.read_sbml_model(filename) except cobra.io.sbml.CobraSBMLError: - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() + exstr = get_last_exception_string() QMessageBox.warning( self, 'Could not read sbml.', exstr) return @@ -1247,9 +1249,7 @@ def open_project(self, filename): cobra_py_model = CNApyModel.read_sbml_model( temp_dir.name + "/model.sbml") except cobra.io.sbml.CobraSBMLError: - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() + exstr = get_last_exception_string() QMessageBox.warning( self, 'Could not open project.', exstr) return @@ -1293,9 +1293,7 @@ def open_project(self, filename): self.appdata.save_cnapy_config() self.build_recent_cna_menu() except FileNotFoundError: - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() + exstr = get_last_exception_string() QMessageBox.warning(self, 'Could not open project.', exstr) except BadZipFile: QMessageBox.critical( @@ -1400,9 +1398,7 @@ def continue_save_project(self): try: self.save_sbml(tmp_dir + "model.sbml") except ValueError: - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() + exstr = get_last_exception_string() utils.show_unknown_error_box(exstr) return @@ -1575,22 +1571,28 @@ def fba(self): self.process_fba_solution() def process_fba_solution(self, update=True): - if self.appdata.project.solution.status == 'optimal': - display_text = "Optimal solution with objective value "+self.appdata.format_flux_value(self.appdata.project.solution.objective_value) - self.set_status_optimal() - for r, v in self.appdata.project.solution.fluxes.items(): - self.appdata.project.comp_values[r] = (v, v) - elif self.appdata.project.solution.status == 'infeasible': - display_text = "No solution, the current scenario is infeasible" - self.set_status_infeasible() - self.appdata.project.comp_values.clear() - else: - display_text = "No optimal solution, solver status is "+self.appdata.project.solution.status - self.set_status_unknown() - self.appdata.project.comp_values.clear() - self.centralWidget().console._append_plain_text("\n"+display_text, before_prompt=True) - self.solver_status_display.setText(display_text) - self.appdata.project.comp_values_type = 0 + general_solution_error = True + if hasattr(self.appdata.project, "solution"): + if hasattr(self.appdata.project.solution, "status"): + general_solution_error = False + if self.appdata.project.solution.status == 'optimal': + display_text = "Optimal solution with objective value "+self.appdata.format_flux_value(self.appdata.project.solution.objective_value) + self.set_status_optimal() + for r, v in self.appdata.project.solution.fluxes.items(): + self.appdata.project.comp_values[r] = (v, v) + elif self.appdata.project.solution.status == 'infeasible': + display_text = "No solution, the current scenario is infeasible" + self.set_status_infeasible() + self.appdata.project.comp_values.clear() + else: + display_text = "No optimal solution, solver status is "+self.appdata.project.solution.status + self.set_status_unknown() + self.appdata.project.comp_values.clear() + + if not general_solution_error: + self.centralWidget().console._append_plain_text("\n"+display_text, before_prompt=True) + self.solver_status_display.setText(display_text) + self.appdata.project.comp_values_type = 0 if update: self.centralWidget().update() @@ -1626,11 +1628,13 @@ def pfba(self): display_text = "An unexpected error occured." self.set_status_unknown() self.appdata.project.comp_values.clear() - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() - print(exstr) - utils.show_unknown_error_box(exstr) + exstr = get_last_exception_string() + # Check for substrings of Gurobi and CPLEX community edition errors + if has_community_error_substring(exstr): + except_likely_community_model_error() + else: + print(exstr) + utils.show_unknown_error_box(exstr) else: if solution.status == 'optimal': soldict = solution.fluxes.to_dict() @@ -1707,7 +1711,7 @@ def net_conversion(self): return print( - '\x1b[1;04;30m'+"Net conversion of external metabolites by the given scenario is:\x1b[0m\n") + '\n\x1b[1;04;30m'+"Net conversion of external metabolites by the given scenario is:\x1b[0m\n") print(' + '.join(imports)) print('-->') print(' + '.join(exports)) @@ -1804,11 +1808,13 @@ def fva(self, fraction_of_optimum=0.0, zero_objective_with_zero_fraction_of_opti QMessageBox.information( self, 'No solution', 'The scenario is infeasible') except Exception: - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() - print(exstr) - utils.show_unknown_error_box(exstr) + exstr = get_last_exception_string() + # Check for substrings of Gurobi and CPLEX community edition errors + if has_community_error_substring(exstr): + except_likely_community_model_error() + else: + print(exstr) + utils.show_unknown_error_box(exstr) else: minimum = solution.minimum.to_dict() maximum = solution.maximum.to_dict() @@ -1831,6 +1837,134 @@ def in_out_flux(self): self.appdata) in_out_flux_dialog.exec_() + def all_in_out_fluxes(self): + filename = self._get_filename("xlsx") + if filename is None: + return + + soldict = { + id: val[0] for (id, val) in self.appdata.project.comp_values.items() + } + + fluxes_per_metabolite = {} + with self.appdata.project.cobra_py_model as model: + self.appdata.project.scen_values.add_scenario_reactions_to_model(model) + for metabolite in model.metabolites: + for reaction in metabolite.reactions: + if reaction.id not in soldict.keys(): + continue + if abs(soldict[reaction.id]) < self.appdata.project.cobra_py_model.tolerance: + continue + + if (metabolite.id, metabolite.name) not in fluxes_per_metabolite.keys(): + fluxes_per_metabolite[(metabolite.id, metabolite.name)] = [] + + stoichiometry = reaction.metabolites[metabolite] + fluxes_per_metabolite[(metabolite.id, metabolite.name)].append([ + stoichiometry * soldict[reaction.id], + reaction.id, + reaction.reaction, + ]) + + # Sheet styles + italic = openpyxl.styles.Font(italic=True) + bold = openpyxl.styles.Font(bold=True) + underlined = openpyxl.styles.Font(underline="single") + + # Main spreadsheet variable + wb = openpyxl.Workbook() + + # In and out sums sheet + ws1 = wb.create_sheet("In and out sums") + cell = ws1.cell(1, 1) + cell.value = "Metabolite ID" + cell.font = bold + + cell = ws1.cell(1, 2) + cell.value = "Metabolite name" + cell.font = bold + + cell = ws1.cell(1, 3) + cell.value = "In flux sum" + cell.font = bold + + cell = ws1.cell(1, 4) + cell.value = "Out flux sum" + cell.font = bold + + current_line = 2 + for met_data, reac_data in fluxes_per_metabolite.items(): + positive_fluxes = [x[0] for x in reac_data if x[0] > 0.0] + negative_fluxes = [x[0] for x in reac_data if x[0] < 0.0] + + cell = ws1.cell(current_line, 1) + cell.value = met_data[0] + cell = ws1.cell(current_line, 2) + cell.value = met_data[1] + cell = ws1.cell(current_line, 3) + cell.value = sum(positive_fluxes) + cell = ws1.cell(current_line, 4) + cell.value = sum(negative_fluxes) + + current_line += 1 + + ws1.column_dimensions['A'].width = 16 + ws1.column_dimensions['B'].width = 18 + ws1.column_dimensions['C'].width = 16 + ws1.column_dimensions['D'].width = 16 + + # Details sheet + ws2 = wb.create_sheet("Details") + current_line = 1 + for met_data, reac_data in fluxes_per_metabolite.items(): + positive_reactions = [x for x in reac_data if x[0] > 0.0] + positive_reactions = sorted(positive_reactions, key=lambda x: x[1]) + negative_reactions = [x for x in reac_data if x[0] < 0.0] + negative_reactions = sorted(negative_reactions, key=lambda x: x[1]) + + cell = ws2.cell(current_line, 1) + cell.value = "Metabolite ID:" + cell.font = bold + cell = ws2.cell(current_line, 2) + cell.value = met_data[0] + + for reaction_set in (("Producing", positive_reactions), ("Consuming", negative_reactions)): + current_line += 1 + cell = ws2.cell(current_line, 1) + cell.value = f"{reaction_set[0]} reactions:" + cell.font = underlined + + current_line += 1 + cell = ws2.cell(current_line, 1) + cell.value = f"{reaction_set[0]} flux" + cell.font = italic + + cell = ws2.cell(current_line, 2) + cell.value = "Reaction ID" + cell.font = italic + + cell = ws2.cell(current_line, 3) + cell.value = "Reaction string" + cell.font = italic + + for reaction_data in reaction_set[1]: + current_line += 1 + cell = ws2.cell(current_line, 1) + cell.value = reaction_data[0] + cell = ws2.cell(current_line, 2) + cell.value = reaction_data[1] + cell = ws2.cell(current_line, 3) + cell.value = reaction_data[2] + current_line += 2 + + ws2.column_dimensions['A'].width = 20 + ws2.column_dimensions['B'].width = 16 + ws2.column_dimensions['C'].width = 16 + + del(wb["Sheet"]) + wb.save(filename) + + def efmtool(self): self.efmtool_dialog = EFMtoolDialog( self.appdata, self.centralWidget()) @@ -1897,6 +2031,8 @@ def in_out_fluxes(self, metabolite_id, soldict): self.centralWidget().kernel_client.execute('%matplotlib qt', store_history=False) + return prod, cons + def show_console(self): print("show model view") (x, _) = self.centralWidget().splitter.sizes() @@ -2141,7 +2277,8 @@ def load_dG0_xlsx(self): ) self._set_dG0s(dG0s) - def _save_fluxes(self, filetype: str): + + def _get_filename(self, filetype: str) -> str: dialog = QFileDialog(self) filename: str = dialog.getSaveFileName( directory=self.appdata.work_directory, filter=f"*.{filetype}")[0] @@ -2149,6 +2286,11 @@ def _save_fluxes(self, filetype: str): return if not (filename.endswith(f".{filetype}")): filename += f".{filetype}" + return filename + + + def _save_fluxes(self, filetype: str): + filename = self._get_filename(filetype) table = self.central_widget.reaction_list.get_as_table() diff --git a/cnapy/gui_elements/mcs_dialog.py b/cnapy/gui_elements/mcs_dialog.py index 38289cbc..16103929 100644 --- a/cnapy/gui_elements/mcs_dialog.py +++ b/cnapy/gui_elements/mcs_dialog.py @@ -15,6 +15,7 @@ from cnapy.appdata import AppData import cnapy.utils as utils from cnapy.flux_vector_container import FluxVectorContainer +from cnapy.core_gui import except_likely_community_model_error, get_last_exception_string, has_community_error_substring class MCSDialog(QDialog): @@ -379,9 +380,10 @@ def compute_optlang(self): QMessageBox.warning(self, 'Cannot calculate MCS', str(e)) return targets, desired except Exception: - output = io.StringIO() - traceback.print_exc(file=output) - exstr = output.getvalue() + exstr = get_last_exception_string() + if has_community_error_substring(exstr): + except_likely_community_model_error() + return print(exstr) utils.show_unknown_error_box(exstr) return targets, desired @@ -469,15 +471,13 @@ def check_left_mcs_equation(self, equation: str) -> str: is_start = False if (last_is_multiplication or last_is_division) and (semantic in ("multiplication", "division")): - errors += f"ERROR in {equation}:\n* or / must not follow on * or /\n" + errors += f"ERROR in {equation}:\n* or / must not follow * or /\n" if last_is_dash and (semantic in ("multiplication", "division")): - errors += f"ERROR in {equation}:\n* or / must not follow on + or -\n" - if last_is_number and (semantic == "reaction"): - errors += f"ERROR in {equation}:\nA reaction must not directly follow on a number without a mathematical operation\n" + errors += f"ERROR in {equation}:\n* or / must not follow + or -\n" if last_is_reaction and (semantic == "reaction"): - errors += f"ERROR in {equation}:\nA reaction must not follow on a reaction ID\n" + errors += f"ERROR in {equation}:\nA reaction must not follow a reaction ID\n" if last_is_number and (semantic == "number"): - errors += f"ERROR in {equation}:\nA number must not follow on a number ID\n" + errors += f"ERROR in {equation}:\nA number must not follow a number ID\n" if prelast_is_reaction and last_is_multiplication and (semantic == "reaction"): errors += f"ERROR in {equation}:\nTwo reactions must not be multiplied together\n" diff --git a/cnapy/gui_elements/reactions_list.py b/cnapy/gui_elements/reactions_list.py index 962e44ac..7a03049d 100644 --- a/cnapy/gui_elements/reactions_list.py +++ b/cnapy/gui_elements/reactions_list.py @@ -303,7 +303,8 @@ def reaction_selected(self, item: ReactionListItem): self.reaction_mask.is_valid = True (_, r) = self.splitter.getRange(1) - self.splitter.moveSplitter(r/2, 1) + self.splitter.moveSplitter(int(r/2), 1) + self.reaction_list.scrollToItem(item) self.reaction_mask.update_state() self.central_widget.add_model_item_to_history(reaction.id, reaction.name, ModelItemType.Reaction) diff --git a/cnapy/gui_elements/strain_design_dialog.py b/cnapy/gui_elements/strain_design_dialog.py index 192c79ca..3926f456 100644 --- a/cnapy/gui_elements/strain_design_dialog.py +++ b/cnapy/gui_elements/strain_design_dialog.py @@ -1206,7 +1206,7 @@ def load(self, sd_setup = {}): sd_setup = json.loads(sd_setup) # warn if strain design setup was constructed for another model if sd_setup[MODEL_ID] != self.appdata.project.cobra_py_model.id: - QMessageBox.information(self,"Model IDs not matching",+\ + QMessageBox.information(self,"Model IDs not matching", "The strain design setup was specified for a different model. "+\ "Errors might occur due to non-matching reaction or gene-identifiers.") # write back content to dialog @@ -1299,12 +1299,12 @@ def compute(self): if not valid: return if not self.modules: - QMessageBox.information(self,"Please add modules",\ + QMessageBox.information(self,"Please add modules", "At least one module must be added to the "+\ "strain design problem.") return if any([True for m in self.modules if m is None]): - QMessageBox.information(self,"Please complete module setup",\ + QMessageBox.information(self,"Please complete module setup", "Some modules were added to the strain design problem "+\ "but not yet set up. Please use the Edit button(s) in the " +\ "module list to ensure all modules were set up correctly.") @@ -1314,7 +1314,7 @@ def compute(self): bilvl_modules = [i for i,m in enumerate(self.modules) \ if m[MODULE_TYPE] in [OPTKNOCK,ROBUSTKNOCK,OPTCOUPLE]] if len(bilvl_modules) > 1: - QMessageBox.information(self,"Conflicting Modules",+\ + QMessageBox.information(self, "Conflicting Modules", "Only one of the module types 'OptKnock', " +\ "'RobustKnock' and 'OptCouple' can be defined per " +\ "strain design setup.") @@ -1591,7 +1591,7 @@ def __init__(self, appdata: AppData, solutions): self.setLayout(self.layout) self.show() if self.solutions.has_complex_regul_itv: - QMessageBox.information(self,"Non-trivial regulatory interventions",\ + QMessageBox.information(self,"Non-trivial regulatory interventions", "The strain design contains 'complex' " +\ "regulatory interventions that cannot be shown " +\ "in the network map. Please refer to table.") diff --git a/cnapy/gui_elements/thermodynamics_dialog.py b/cnapy/gui_elements/thermodynamics_dialog.py index 991af7a8..45fce32c 100644 --- a/cnapy/gui_elements/thermodynamics_dialog.py +++ b/cnapy/gui_elements/thermodynamics_dialog.py @@ -32,7 +32,7 @@ from cnapy.sd_ci_optmdfpathway import create_optmdfpathway_milp, STANDARD_R, STANDARD_T from typing import Dict from cobra.util.solver import interface_to_str -from cnapy.core import model_optimization_with_exceptions +from cnapy.core_gui import model_optimization_with_exceptions import re from cnapy.gui_elements.solver_buttons import get_solver_buttons from straindesign.names import CPLEX, GLPK, GUROBI, SCIP