diff --git a/biosimulators_utils/_version.py b/biosimulators_utils/_version.py index f9b925bf..8dcfa7de 100644 --- a/biosimulators_utils/_version.py +++ b/biosimulators_utils/_version.py @@ -1 +1 @@ -__version__ = '0.1.31' +__version__ = '0.1.32' diff --git a/biosimulators_utils/combine/exceptions.py b/biosimulators_utils/combine/exceptions.py new file mode 100644 index 00000000..29ff456f --- /dev/null +++ b/biosimulators_utils/combine/exceptions.py @@ -0,0 +1,18 @@ +""" Exceptions for COMBINE/OMEX archives + +:Author: Jonathan Karr +:Date: 2021-01-12 +:Copyright: 2021, Center for Reproducible Biomedical Modeling +:License: MIT +""" + +from ..exceptions import BioSimulatorsException + +__all__ = [ + 'CombineArchiveExecutionError', +] + + +class CombineArchiveExecutionError(BioSimulatorsException): + """ Error that a SED document could not be executed """ + pass # pragma: no cover diff --git a/biosimulators_utils/combine/exec.py b/biosimulators_utils/combine/exec.py index 270bc170..2ffc4d3f 100644 --- a/biosimulators_utils/combine/exec.py +++ b/biosimulators_utils/combine/exec.py @@ -9,21 +9,25 @@ from ..archive.io import ArchiveWriter from ..archive.utils import build_archive_from_paths from ..config import get_config -from ..log.data_model import Status -from ..log.utils import init_combine_archive_log +from ..log.data_model import Status, CombineArchiveLog # noqa: F401 +from ..log.utils import init_combine_archive_log, get_summary_combine_archive_log from ..plot.data_model import PlotFormat # noqa: F401 from ..report.data_model import DataGeneratorVariableResults, OutputResults, ReportFormat # noqa: F401 -from ..sedml.data_model import Task, DataGeneratorVariable # noqa: F401 +from ..sedml.data_model import (SedDocument, Task, Output, Report, DataSet, Plot2D, Curve, # noqa: F401 + Plot3D, Surface, DataGeneratorVariable) from ..sedml.io import SedmlSimulationReader # noqa: F401 +from ..warnings import warn +from .exceptions import CombineArchiveExecutionError from .io import CombineArchiveReader from .utils import get_sedml_contents, get_summary_sedml_contents from .warnings import NoSedmlWarning +import capturer +import datetime import glob import os import tempfile import shutil import types # noqa: F401 -import warnings __all__ = [ 'exec_sedml_docs_in_archive', @@ -31,8 +35,10 @@ def exec_sedml_docs_in_archive(sed_doc_executer, archive_filename, out_dir, apply_xml_model_changes=False, + sed_doc_executer_supported_features=(Task, Report, DataSet, Plot2D, Curve, Plot3D, Surface), report_formats=None, plot_formats=None, - bundle_outputs=None, keep_individual_outputs=None): + bundle_outputs=None, keep_individual_outputs=None, + sed_doc_executer_logged_features=(Task, Report, DataSet, Plot2D, Curve, Plot3D, Surface)): """ Execute the SED-ML files in a COMBINE/OMEX archive (execute tasks and save outputs) Args: @@ -73,10 +79,17 @@ def sed_doc_executer(doc, working_dir, base_out_path, rel_out_path=None, apply_xml_model_changes (:obj:`bool`): if :obj:`True`, apply any model changes specified in the SED-ML files before calling :obj:`task_executer`. + sed_doc_executer_supported_features (:obj:`list` of :obj:`type`, optional): list of the types of elements that the + SED document executer supports. Default: tasks, reports, plots, data sets, curves, and surfaces. report_formats (:obj:`list` of :obj:`ReportFormat`, optional): report format (e.g., csv or h5) plot_formats (:obj:`list` of :obj:`PlotFormat`, optional): report format (e.g., pdf) bundle_outputs (:obj:`bool`, optional): if :obj:`True`, bundle outputs into archives for reports and plots keep_individual_outputs (:obj:`bool`, optional): if :obj:`True`, keep individual output files + sed_doc_executer_logged_features (:obj:`list` of :obj:`type`, optional): list of the types fo elements which that + the SED document executer logs. Default: tasks, reports, plots, data sets, curves, and surfaces. + + Returns: + :obj:`CombineArchiveLog`: log """ config = get_config() @@ -93,6 +106,8 @@ def sed_doc_executer(doc, working_dir, base_out_path, rel_out_path=None, if keep_individual_outputs is None: keep_individual_outputs = config.KEEP_INDIVIDUAL_OUTPUTS + verbose = config.VERBOSE + # create temporary directory to unpack archive archive_tmp_dir = tempfile.mkdtemp() @@ -102,7 +117,7 @@ def sed_doc_executer(doc, working_dir, base_out_path, rel_out_path=None, # determine files to execute sedml_contents = get_sedml_contents(archive) if not sedml_contents: - warnings.warn("COMBINE/OMEX archive '{}' does not contain any executing SED-ML files".format(archive_filename), NoSedmlWarning) + warn("COMBINE/OMEX archive '{}' does not contain any executing SED-ML files".format(archive_filename), NoSedmlWarning) # print summary of SED documents print(get_summary_sedml_contents(archive, archive_tmp_dir)) @@ -112,30 +127,64 @@ def sed_doc_executer(doc, working_dir, base_out_path, rel_out_path=None, os.makedirs(out_dir) # initialize status and output - log = init_combine_archive_log(archive, archive_tmp_dir) + supported_features = sed_doc_executer_supported_features + logged_features = sed_doc_executer_logged_features + + if SedDocument not in supported_features: + supported_features = tuple(list(supported_features) + [SedDocument]) + + if SedDocument not in logged_features: + logged_features = tuple(list(logged_features) + [SedDocument]) + + log = init_combine_archive_log(archive, archive_tmp_dir, + supported_features=supported_features, + logged_features=logged_features) log.status = Status.RUNNING log.out_dir = out_dir log.export() + start_time = datetime.datetime.now() # execute SED-ML files: execute tasks and save output + exceptions = [] for i_content, content in enumerate(sedml_contents): content_filename = os.path.join(archive_tmp_dir, content.location) content_id = os.path.relpath(content_filename, archive_tmp_dir) - print('Executing SED-ML file {}: {}'.format(i_content, content_id)) - - working_dir = os.path.dirname(content_filename) - sed_doc_executer(content_filename, - working_dir, - out_dir, - os.path.relpath(content_filename, archive_tmp_dir), - apply_xml_model_changes=apply_xml_model_changes, - report_formats=report_formats, - plot_formats=plot_formats, - log=log.sed_documents[content_id], - indent=1) + print('Executing SED-ML file {}: {} ...'.format(i_content, content_id)) + + doc_log = log.sed_documents[content_id] + doc_log.status = Status.RUNNING + doc_log.export() + + with capturer.CaptureOutput(merged=True, relay=verbose) as captured: + doc_start_time = datetime.datetime.now() + try: + working_dir = os.path.dirname(content_filename) + sed_doc_executer(content_filename, + working_dir, + out_dir, + os.path.relpath(content_filename, archive_tmp_dir), + apply_xml_model_changes=apply_xml_model_changes, + report_formats=report_formats, + plot_formats=plot_formats, + log=doc_log, + indent=1) + doc_log.status = Status.SUCCEEDED + except Exception as exception: + exceptions.append(exception) + doc_log.status = Status.FAILED + doc_log.exception = exception + + # update status + doc_log.output = captured.get_bytes().decode() + doc_log.duration = (datetime.datetime.now() - doc_start_time).total_seconds() + doc_log.export() + + print('') if bundle_outputs: + print('Bundling outputs ...') + # bundle CSV files of reports into zip archive archive_paths = [os.path.join(out_dir, '**', '*.' + format.value) for format in report_formats if format != ReportFormat.h5] archive = build_archive_from_paths(archive_paths, out_dir) @@ -149,7 +198,9 @@ def sed_doc_executer(doc, working_dir, base_out_path, rel_out_path=None, ArchiveWriter().run(archive, os.path.join(out_dir, config.PLOTS_PATH)) # cleanup temporary files + print('Cleaning up ...') if not keep_individual_outputs: + path_patterns = ( [os.path.join(out_dir, '**', '*.' + format.value) for format in report_formats if format != ReportFormat.h5] + [os.path.join(out_dir, '**', '*.' + format.value) for format in plot_formats] @@ -174,6 +225,21 @@ def sed_doc_executer(doc, working_dir, base_out_path, rel_out_path=None, shutil.rmtree(archive_tmp_dir) # update status - log.status = Status.SUCCEEDED + log.status = Status.FAILED if exceptions else Status.SUCCEEDED + log.duration = (datetime.datetime.now() - start_time).total_seconds() log.finalize() log.export() + + # summarize execution + print('') + print('============= SUMMARY =============') + print(get_summary_combine_archive_log(log)) + + # raise exceptions + if exceptions: + msg = 'The COMBINE/OMEX did not execute successfully:\n\n {}'.format( + '\n\n '.join(str(exceptions).replace('\n', '\n ') for exceptions in exceptions)) + raise CombineArchiveExecutionError(msg) + + # return log + return log diff --git a/biosimulators_utils/combine/io.py b/biosimulators_utils/combine/io.py index 83197f9b..886cfa69 100644 --- a/biosimulators_utils/combine/io.py +++ b/biosimulators_utils/combine/io.py @@ -9,10 +9,10 @@ from .data_model import CombineArchiveBase, CombineArchive, CombineArchiveContent, CombineArchiveContentFormat # noqa: F401 from ..archive.io import ArchiveReader from ..data_model import Person +from ..warnings import warn import dateutil.parser import libcombine import os -import warnings import zipfile __all__ = [ @@ -119,7 +119,7 @@ def run(cls, in_file, out_dir, try_reading_as_plain_zip_archive=True): if try_reading_as_plain_zip_archive: try: archive = CombineArchiveZipReader().run(in_file, out_dir) - warnings.warn('`{}` is a plain zip archive, not a COMBINE/OMEX archive.'.format(in_file), UserWarning) + warn('`{}` is a plain zip archive, not a COMBINE/OMEX archive.'.format(in_file), UserWarning) return archive except ValueError: raise ValueError("`{}` is not a valid COMBINE/OMEX archive.".format(in_file)) diff --git a/biosimulators_utils/config.py b/biosimulators_utils/config.py index e85e2c06..aef3a28f 100644 --- a/biosimulators_utils/config.py +++ b/biosimulators_utils/config.py @@ -6,8 +6,11 @@ :License: MIT """ +import enum import os +__all__ = ['Config', 'get_config', 'Colors'] + class Config(object): """ Configuration @@ -23,11 +26,12 @@ class Config(object): KEEP_INDIVIDUAL_OUTPUTS (:obj:`bool`): indicates whether the individual output files should be kept LOG_PATH (:obj:`str`): path to save the execution status of a COMBINE/OMEX archive BIOSIMULATORS_API_ENDPOINT (:obj:`str`): URL for BioSimulators API + VERBOSE (:obj:`bool`): whether to display the detailed output of the execution of each task """ def __init__(self, ALGORITHM_SUBSTITUTION_POLICY, REPORT_FORMATS, PLOT_FORMATS, H5_REPORTS_PATH, REPORTS_PATH, PLOTS_PATH, BUNDLE_OUTPUTS, KEEP_INDIVIDUAL_OUTPUTS, - LOG_PATH, BIOSIMULATORS_API_ENDPOINT): + LOG_PATH, BIOSIMULATORS_API_ENDPOINT, VERBOSE): """ Args: ALGORITHM_SUBSTITUTION_POLICY (:obj:`str`): algorithm substition policy @@ -40,6 +44,7 @@ def __init__(self, ALGORITHM_SUBSTITUTION_POLICY, REPORT_FORMATS, PLOT_FORMATS, KEEP_INDIVIDUAL_OUTPUTS (:obj:`bool`): indicates whether the individual output files should be kept LOG_PATH (:obj:`str`): path to save the execution status of a COMBINE/OMEX archive BIOSIMULATORS_API_ENDPOINT (:obj:`str`): URL for BioSimulators API + VERBOSE (:obj:`bool`): whether to display the detailed output of the execution of each task """ self.ALGORITHM_SUBSTITUTION_POLICY = ALGORITHM_SUBSTITUTION_POLICY self.REPORT_FORMATS = REPORT_FORMATS @@ -51,6 +56,7 @@ def __init__(self, ALGORITHM_SUBSTITUTION_POLICY, REPORT_FORMATS, PLOT_FORMATS, self.KEEP_INDIVIDUAL_OUTPUTS = KEEP_INDIVIDUAL_OUTPUTS self.LOG_PATH = LOG_PATH self.BIOSIMULATORS_API_ENDPOINT = BIOSIMULATORS_API_ENDPOINT + self.VERBOSE = VERBOSE def get_config(): @@ -70,4 +76,23 @@ def get_config(): KEEP_INDIVIDUAL_OUTPUTS=os.environ.get('KEEP_INDIVIDUAL_OUTPUTS', '1').lower() in ['1', 'true'], LOG_PATH=os.environ.get('LOG_PATH', 'log.yml'), BIOSIMULATORS_API_ENDPOINT=os.environ.get('BIOSIMULATORS_API_ENDPOINT', 'https://api.biosimulators.org/'), + VERBOSE=os.environ.get('VERBOSE', '1').lower() in ['1', 'true'], ) + + +Colors = enum.Enum('Colors', + { + 'queued': 'cyan', + 'success': 'blue', + 'succeeded': 'blue', + 'running': 'green', + 'pass': 'green', + 'passed': 'green', + 'failure': 'red', + 'failed': 'red', + 'skip': 'magenta', + 'skipped': 'magenta', + 'warning': 'yellow', + 'warned': 'yellow', + }, + type=str) diff --git a/biosimulators_utils/gh_action/data_model.py b/biosimulators_utils/gh_action/data_model.py index 7e0385d2..1f4e762f 100644 --- a/biosimulators_utils/gh_action/data_model.py +++ b/biosimulators_utils/gh_action/data_model.py @@ -6,13 +6,15 @@ :License: MIT """ +from ..exceptions import BioSimulatorsException + __all__ = [ 'GitHubActionCaughtError', 'Comment', ] -class GitHubActionCaughtError(Exception): +class GitHubActionCaughtError(BioSimulatorsException): """ An error caught during the execution of a GitHub action """ pass # pragma: no cover diff --git a/biosimulators_utils/kisao/utils.py b/biosimulators_utils/kisao/utils.py index 8283ec60..cb2e0cb5 100644 --- a/biosimulators_utils/kisao/utils.py +++ b/biosimulators_utils/kisao/utils.py @@ -6,9 +6,9 @@ :License: MIT """ +from ..warnings import warn from .warnings import InvalidKisaoTermIdWarning import re -import warnings __all__ = ['normalize_kisao_id'] @@ -36,6 +36,6 @@ def normalize_kisao_id(id): id = 'KISAO_' + '0' * (7 - len(id)) + id if not re.match(r'KISAO_\d{7}', id): - warnings.warn("'{}' is likely not an id for a KiSAO term".format(unnormalized_id), InvalidKisaoTermIdWarning) + warn("'{}' is likely not an id for a KiSAO term".format(unnormalized_id), InvalidKisaoTermIdWarning) return id diff --git a/biosimulators_utils/log/data_model.py b/biosimulators_utils/log/data_model.py index 75ea82d7..4f12a39a 100644 --- a/biosimulators_utils/log/data_model.py +++ b/biosimulators_utils/log/data_model.py @@ -8,16 +8,16 @@ from ..config import get_config import enum -import itertools import os import yaml __all__ = [ 'Status', + 'Log', 'CombineArchiveLog', 'SedDocumentLog', 'TaskLog', - 'OutLog', + 'OutputLog', 'ReportLog', 'Plot2DLog', 'Plot3DLog', @@ -25,7 +25,7 @@ class Status(str, enum.Enum): - """ Execution status of a component of a COMBINE/OMEX archive """ + """ Status of COMBINE/OMEX archive or one of its components """ QUEUED = 'QUEUED' RUNNING = 'RUNNING' @@ -39,94 +39,128 @@ class Status(str, enum.Enum): FAILED = 'FAILED' -class CombineArchiveLog(object): - """ Execution status of a COMBINE/OMEX archive +class Log(object): + """ Log of a COMBINE/OMEX archive or one of its components Attributes status (:obj:`Status`): execution status of the archive - sed_documents (:obj:`dict` of :obj:`str` to :obj:`SedDocumentLog`): execution status of each - SED document in the archive + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds + parent (:obj:`Log`): execution status of parent COMBINE/OMEX archive out_dir (:obj:`str`): directory to export status """ - def __init__(self, status=None, sed_documents=None, out_dir=None): + def __init__(self, status=None, exception=None, skip_reason=None, output=None, duration=None, parent=None, out_dir=None): """ Args: status (:obj:`Status`, optional): execution status of the archive - sed_documents (:obj:`dict` of :obj:`str` to :obj:`SedDocumentLog`, optional): execution status of each - SED document in the archive + exception (:obj:`Exception`, optional): exception + skip_reason (:obj:`Exception`, optional): reason of skip + output (:obj:`str`, optional): output + duration (:obj:`float`, optional): duration in seconds + parent (:obj:`Log`, optional): execution status of parent COMBINE/OMEX archive out_dir (:obj:`str`, optional): directory to export status """ self.status = status - self.sed_documents = sed_documents or {} + self.exception = exception + self.skip_reason = skip_reason + self.output = output + self.duration = duration + self.parent = parent self.out_dir = out_dir def finalize(self): - """ Mark all unexceuted elements as skipped """ + """ Mark all unexecuted elements as skipped """ if self.status == Status.QUEUED: self.status = Status.SKIPPED elif self.status == Status.RUNNING: self.status = Status.FAILED - for sed_document in self.sed_documents.values(): - sed_document.finalize() - def to_dict(self): """ Generate a JSON-compatible representation Returns: :obj:`dict`: JSON-compatible representation """ - return { - 'status': self.status.value if self.status else None, - 'sedDocuments': {doc_id: doc_status.to_dict() for doc_id, doc_status in self.sed_documents.items()}, - } + value = {} + + value['status'] = self.status.value if self.status else None + + if self.exception: + value['exception'] = { + 'type': self.exception.__class__.__name__, + 'message': str(self.exception), + } + else: + value['exception'] = None + + if self.skip_reason: + value['skipReason'] = { + 'type': self.skip_reason.__class__.__name__, + 'message': str(self.skip_reason), + } + else: + value['skipReason'] = None + + value['output'] = self.output + + value['duration'] = self.duration + + return value def export(self): """ Write to a file """ - path = os.path.join(self.out_dir, get_config().LOG_PATH) - if not os.path.isdir(self.out_dir): - os.makedirs(self.out_dir) - with open(path, 'w') as file: - file.write(yaml.dump(self.to_dict())) + if self.out_dir: + path = os.path.join(self.out_dir, get_config().LOG_PATH) + if not os.path.isdir(self.out_dir): + os.makedirs(self.out_dir) + with open(path, 'w') as file: + file.write(yaml.dump(self.to_dict())) + elif self.parent: + self.parent.export() -class SedDocumentLog(object): - """ Execution status of a SED document +class CombineArchiveLog(Log): + """ Log of a COMBINE/OMEX archive Attributes status (:obj:`Status`): execution status of the archive - tasks (:obj:`dict` of :obj:`str` to :obj:`TaskLog`): execution status of each - task - outputs (:obj:`dict` of :obj:`str` to :obj:`OutLog`): execution status of each - output - combine_archive_status (:obj:`CombineArchiveLog`): execution status of parent COMBINE/OMEX archive + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds + sed_documents (:obj:`dict` of :obj:`str` to :obj:`SedDocumentLog`): execution status of each + SED document in the archive + out_dir (:obj:`str`): directory to export status """ - def __init__(self, status=None, tasks=None, outputs=None, combine_archive_status=None): + def __init__(self, status=None, exception=None, skip_reason=None, output=None, duration=None, sed_documents=None, + out_dir=None): """ Args: status (:obj:`Status`, optional): execution status of the archive - tasks (:obj:`dict` of :obj:`str` to :obj:`TaskLog`, optional): execution status of each - task - outputs (:obj:`dict` of :obj:`str` to :obj:`OutLog`, optional): execution status of each - output - combine_archive_status (:obj:`CombineArchiveLog`, optional): execution status of parent COMBINE/OMEX archive + exception (:obj:`Exception`, optional): exception + skip_reason (:obj:`Exception`, optional): reason of skip + output (:obj:`str`, optional): output + duration (:obj:`float`, optional): duration in seconds + sed_documents (:obj:`dict` of :obj:`str` to :obj:`SedDocumentLog`, optional): execution status of each + SED document in the archive + out_dir (:obj:`str`, optional): directory to export status """ - self.status = status - self.tasks = tasks or {} - self.outputs = outputs or {} - self.combine_archive_status = combine_archive_status + super(CombineArchiveLog, self).__init__(status=status, exception=exception, + skip_reason=skip_reason, output=output, + duration=duration, out_dir=out_dir) + self.sed_documents = sed_documents def finalize(self): """ Mark all unexceuted elements as skipped """ - if self.status == Status.QUEUED: - self.status = Status.SKIPPED - elif self.status == Status.RUNNING: - self.status = Status.FAILED + super(CombineArchiveLog, self).finalize() - for el in itertools.chain(self.tasks.values(), self.outputs.values()): - el.finalize() + if self.sed_documents: + for sed_document in self.sed_documents.values(): + sed_document.finalize() def to_dict(self): """ Generate a JSON-compatible representation @@ -134,40 +168,65 @@ def to_dict(self): Returns: :obj:`dict`: JSON-compatible representation """ - return { - 'status': self.status.value if self.status else None, - 'tasks': {task_id: task_status.to_dict() for task_id, task_status in self.tasks.items()}, - 'outputs': {output_id: output_status.to_dict() for output_id, output_status in self.outputs.items()}, - } - - def export(self): - """ Write to a file """ - self.combine_archive_status.export() + value = super(CombineArchiveLog, self).to_dict() + value['sedDocuments'] = ( + {doc_id: (doc_log.to_dict() if doc_log else None) for doc_id, doc_log in self.sed_documents.items()} + if self.sed_documents is not None + else None + ) + return value -class TaskLog(object): - """ Execution status of a SED task +class SedDocumentLog(Log): + """ Log of a SED document Attributes - status (:obj:`Status`): execution status of the task - document_status (:obj:`SedDocumentLog`): execution status of parent SED document + status (:obj:`Status`): execution status of the archive + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds + tasks (:obj:`dict` of :obj:`str` to :obj:`TaskLog`): execution status of each + task + outputs (:obj:`dict` of :obj:`str` to :obj:`OutputLog`): execution status of each + output + parent (:obj:`CombineArchiveLog`): execution status of parent COMBINE/OMEX archive + out_dir (:obj:`str`): directory to export status """ - def __init__(self, status=None, document_status=None): + def __init__(self, status=None, exception=None, skip_reason=None, output=None, duration=None, + tasks=None, outputs=None, parent=None, out_dir=None): """ Args: - status (:obj:`Status`): execution status of the task - document_status (:obj:`SedDocumentLog`, optional): execution status of parent SED document + status (:obj:`Status`, optional): execution status of the archive + exception (:obj:`Exception`, optional): exception + skip_reason (:obj:`Exception`, optional): reason of skip + output (:obj:`str`, optional): output + duration (:obj:`float`, optional): duration in seconds + tasks (:obj:`dict` of :obj:`str` to :obj:`TaskLog`, optional): execution status of each + task + outputs (:obj:`dict` of :obj:`str` to :obj:`OutputLog`, optional): execution status of each + output + parent (:obj:`CombineArchiveLog`, optional): execution status of parent COMBINE/OMEX archive + out_dir (:obj:`str`, optional): directory to export status """ - self.status = status - self.document_status = document_status + super(SedDocumentLog, self).__init__(status=status, exception=exception, + skip_reason=skip_reason, output=output, + duration=duration, parent=parent, out_dir=out_dir) + self.tasks = tasks + self.outputs = outputs def finalize(self): """ Mark all unexceuted elements as skipped """ - if self.status == Status.QUEUED: - self.status = Status.SKIPPED - elif self.status == Status.RUNNING: - self.status = Status.FAILED + super(SedDocumentLog, self).finalize() + + if self.tasks: + for task in self.tasks.values(): + task.finalize() + + if self.outputs: + for output in self.outputs.values(): + output.finalize() def to_dict(self): """ Generate a JSON-compatible representation @@ -175,38 +234,54 @@ def to_dict(self): Returns: :obj:`dict`: JSON-compatible representation """ - return { - 'status': self.status.value if self.status else None, - } - - def export(self): - """ Write to a file """ - self.document_status.export() - - -class OutLog(object): - """ Execution status of a SED output + value = super(SedDocumentLog, self).to_dict() + value['tasks'] = ( + {task_id: (task_log.to_dict() if task_log else None) for task_id, task_log in self.tasks.items()} + if self.tasks is not None + else None + ) + value['outputs'] = ( + {output_id: (output_log.to_dict() if output_log else None) for output_id, output_log in self.outputs.items()} + if self.outputs is not None + else None + ) + return value + + +class TaskLog(Log): + """ Log of a SED task Attributes status (:obj:`Status`): execution status of the archive - document_status (:obj:`SedDocumentLog`): execution status of parent SED document + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds + algorithm (:obj:`str`): KiSAO id of the requested algorithm + simulator_details (:obj:`dict`): additional simulator-specific information + parent (:obj:`SedDocumentLog`): execution status of parent SED document + out_dir (:obj:`str`): directory to export status """ - def __init__(self, status=None, document_status=None): + def __init__(self, status=None, exception=None, skip_reason=None, output=None, duration=None, + algorithm=None, simulator_details=None, parent=None, out_dir=None): """ Args: status (:obj:`Status`, optional): execution status of the archive - document_status (:obj:`SedDocumentLog`, optional): execution status of parent SED document + exception (:obj:`Exception`, optional): exception + skip_reason (:obj:`Exception`, optional): reason of skip + output (:obj:`str`, optional): output + duration (:obj:`float`, optional): duration in seconds + algorithm (:obj:`str`, optional): KiSAO id of the executed algorithm + simulator_details (:obj:`dict`, optional): additional simulator-specific information + parent (:obj:`SedDocumentLog`): execution status of parent SED document + out_dir (:obj:`str`, optional): directory to export status """ - self.status = status - self.document_status = document_status - - def finalize(self): - """ Mark all unexceuted elements as skipped """ - if self.status == Status.QUEUED: - self.status = Status.SKIPPED - elif self.status == Status.RUNNING: - self.status = Status.FAILED + super(TaskLog, self).__init__(status=status, exception=exception, + skip_reason=skip_reason, output=output, + duration=duration, parent=parent, out_dir=out_dir) + self.algorithm = algorithm + self.simulator_details = simulator_details def to_dict(self): """ Generate a JSON-compatible representation @@ -214,45 +289,70 @@ def to_dict(self): Returns: :obj:`dict`: JSON-compatible representation """ - return { - 'status': self.status.value if self.status else None, - } + value = super(TaskLog, self).to_dict() + value['algorithm'] = self.algorithm + value['simulatorDetails'] = self.simulator_details + return value - def export(self): - """ Write to a file """ - self.document_status.export() +class OutputLog(Log): + """ Log of a SED output -class ReportLog(OutLog): - """ Execution status of a SED report + Attributes + status (:obj:`Status`): execution status of the archive + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds + parent (:obj:`SedDocumentLog`): execution status of parent SED document + out_dir (:obj:`str`): directory to export status + """ + pass # pragma: no cover + + +class ReportLog(OutputLog): + """ Log of a SED report Attributes status (:obj:`Status`): execution status of the archive + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds data_sets (:obj:`dict` of :obj:`str` to :obj:`Status`): execution status of each data set - document_status (:obj:`SedDocumentLog`): execution status of parent SED document + parent (:obj:`SedDocumentLog`): execution status of parent SED document + out_dir (:obj:`str`): directory to export status """ - def __init__(self, status=None, data_sets=None, document_status=None): + def __init__(self, status=None, exception=None, skip_reason=None, output=None, duration=None, data_sets=None, + parent=None, out_dir=None): """ Args: status (:obj:`Status`, optional): execution status of the archive + exception (:obj:`Exception`, optional): exception + skip_reason (:obj:`Exception`, optional): reason of skip + output (:obj:`str`, optional): output + duration (:obj:`float`, optional): duration in seconds data_sets (:obj:`dict` of :obj:`str` to :obj:`Status`, optional): execution status of each data set - document_status (:obj:`SedDocumentLog`, optional): execution status of parent SED document + parent (:obj:`SedDocumentLog`, optional): execution status of parent SED document + out_dir (:obj:`str`, optional): directory to export status """ - super(ReportLog, self).__init__(status=status, document_status=document_status) - self.data_sets = data_sets or {} + super(ReportLog, self).__init__(status=status, exception=exception, skip_reason=skip_reason, + output=output, duration=duration, parent=parent, out_dir=out_dir) + self.data_sets = data_sets def finalize(self): """ Mark all unexceuted elements as skipped """ super(ReportLog, self).finalize() - for id, status in self.data_sets.items(): - if status == Status.QUEUED: - self.data_sets[id] = Status.SKIPPED - elif status == Status.RUNNING: - self.data_sets[id] = Status.FAILED + if self.data_sets: + for id, status in self.data_sets.items(): + if status == Status.QUEUED: + self.data_sets[id] = Status.SKIPPED + elif status == Status.RUNNING: + self.data_sets[id] = Status.FAILED def to_dict(self): """ Generate a JSON-compatible representation @@ -260,41 +360,58 @@ def to_dict(self): Returns: :obj:`dict`: JSON-compatible representation """ - dict_status = super(ReportLog, self).to_dict() - dict_status['dataSets'] = {id: status.value if status else None for id, status in self.data_sets.items()} - return dict_status + dict_log = super(ReportLog, self).to_dict() + dict_log['dataSets'] = ( + {id: status.value if status else None for id, status in self.data_sets.items()} + if self.data_sets is not None + else None + ) + return dict_log -class Plot2DLog(OutLog): - """ Execution status of a 2D SED plot +class Plot2DLog(OutputLog): + """ Log of a 2D SED plot Attributes status (:obj:`Status`): execution status of the archive + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds curves (:obj:`dict` of :obj:`str` to :obj:`Status`): execution status of each curve - document_status (:obj:`SedDocumentLog`): execution status of parent SED document + parent (:obj:`SedDocumentLog`): execution status of parent SED document + out_dir (:obj:`str`): directory to export status """ - def __init__(self, status=None, curves=None, document_status=None): + def __init__(self, status=None, exception=None, skip_reason=None, output=None, duration=None, curves=None, + parent=None, out_dir=None): """ Args: status (:obj:`Status`, optional): execution status of the archive + exception (:obj:`Exception`, optional): exception + skip_reason (:obj:`Exception`, optional): reason of skip + output (:obj:`str`, optional): output + duration (:obj:`float`, optional): duration in seconds curves (:obj:`dict` of :obj:`str` to :obj:`Status`, optional): execution status of each curve - document_status (:obj:`SedDocumentLog`, optional): execution status of parent SED document + parent (:obj:`SedDocumentLog`, optional): execution status of parent SED document + out_dir (:obj:`str`, optional): directory to export status """ - super(Plot2DLog, self).__init__(status=status, document_status=document_status) - self.curves = curves or {} + super(Plot2DLog, self).__init__(status=status, exception=exception, skip_reason=skip_reason, + output=output, duration=duration, parent=parent, out_dir=out_dir) + self.curves = curves def finalize(self): """ Mark all unexceuted elements as skipped """ super(Plot2DLog, self).finalize() - for id, status in self.curves.items(): - if status == Status.QUEUED: - self.curves[id] = Status.SKIPPED - elif status == Status.RUNNING: - self.curves[id] = Status.FAILED + if self.curves: + for id, status in self.curves.items(): + if status == Status.QUEUED: + self.curves[id] = Status.SKIPPED + elif status == Status.RUNNING: + self.curves[id] = Status.FAILED def to_dict(self): """ Generate a JSON-compatible representation @@ -302,41 +419,58 @@ def to_dict(self): Returns: :obj:`dict`: JSON-compatible representation """ - dict_status = super(Plot2DLog, self).to_dict() - dict_status['curves'] = {id: status.value if status else None for id, status in self.curves.items()} - return dict_status + dict_log = super(Plot2DLog, self).to_dict() + dict_log['curves'] = ( + {id: status.value if status else None for id, status in self.curves.items()} + if self.curves is not None + else None + ) + return dict_log -class Plot3DLog(OutLog): - """ Execution status of a 3D SED plot +class Plot3DLog(OutputLog): + """ Log of a 3D SED plot Attributes status (:obj:`Status`): execution status of the archive + exception (:obj:`Exception`): exception + skip_reason (:obj:`Exception`): reason of skip + output (:obj:`str`): output + duration (:obj:`float`): duration in seconds surfaces (:obj:`dict` of :obj:`str` to :obj:`Status`): execution status of each surface - document_status (:obj:`SedDocumentLog`): execution status of parent SED document + parent (:obj:`SedDocumentLog`): execution status of parent SED document + out_dir (:obj:`str`): directory to export status """ - def __init__(self, status=None, surfaces=None, document_status=None): + def __init__(self, status=None, exception=None, skip_reason=None, output=None, duration=None, surfaces=None, + parent=None, out_dir=None): """ Args: status (:obj:`Status`, optional): execution status of the archive + exception (:obj:`Exception`, optional): exception + skip_reason (:obj:`Exception`, optional): reason of skip + output (:obj:`str`, optional): output + duration (:obj:`float`, optional): duration in seconds surfaces (:obj:`dict` of :obj:`str` to :obj:`Status`, optional): execution status of each surface - document_status (:obj:`SedDocumentLog`, optional): execution status of parent SED document + parent (:obj:`SedDocumentLog`, optional): execution status of parent SED document + out_dir (:obj:`str`, optional): directory to export status """ - super(Plot3DLog, self).__init__(status=status, document_status=document_status) - self.surfaces = surfaces or {} + super(Plot3DLog, self).__init__(status=status, exception=exception, skip_reason=skip_reason, + output=output, duration=duration, parent=parent, out_dir=out_dir) + self.surfaces = surfaces def finalize(self): """ Mark all unexceuted elements as skipped """ super(Plot3DLog, self).finalize() - for id, status in self.surfaces.items(): - if status == Status.QUEUED: - self.surfaces[id] = Status.SKIPPED - elif status == Status.RUNNING: - self.surfaces[id] = Status.FAILED + if self.surfaces: + for id, status in self.surfaces.items(): + if status == Status.QUEUED: + self.surfaces[id] = Status.SKIPPED + elif status == Status.RUNNING: + self.surfaces[id] = Status.FAILED def to_dict(self): """ Generate a JSON-compatible representation @@ -344,6 +478,10 @@ def to_dict(self): Returns: :obj:`dict`: JSON-compatible representation """ - dict_status = super(Plot3DLog, self).to_dict() - dict_status['surfaces'] = {id: status.value if status else None for id, status in self.surfaces.items()} - return dict_status + dict_log = super(Plot3DLog, self).to_dict() + dict_log['surfaces'] = ( + {id: status.value if status else None for id, status in self.surfaces.items()} + if self.surfaces is not None + else None + ) + return dict_log diff --git a/biosimulators_utils/log/utils.py b/biosimulators_utils/log/utils.py index 14a1724a..72bf0fe5 100644 --- a/biosimulators_utils/log/utils.py +++ b/biosimulators_utils/log/utils.py @@ -8,68 +8,319 @@ from ..combine.data_model import CombineArchive # noqa: F401 from ..combine.utils import get_sedml_contents -from ..sedml.data_model import SedDocument, Task, Report, Plot2D, Plot3D +from ..sedml.data_model import SedDocument, Task, Output, Report, Plot2D, Plot3D, DataSet, Curve, Surface from ..sedml.io import SedmlSimulationReader -from .data_model import (Status, CombineArchiveLog, SedDocumentLog, - TaskLog, ReportLog, Plot2DLog, Plot3DLog) +from .data_model import (Status, CombineArchiveLog, SedDocumentLog, # noqa: F401 + TaskLog, OutputLog, ReportLog, Plot2DLog, Plot3DLog) import os -__all__ = ['init_combine_archive_log'] +__all__ = [ + 'init_combine_archive_log', + 'init_sed_document_log', + 'init_task_log', + 'init_output_log', + 'init_report_log', + 'init_plot2d_log', + 'init_plot3d_log', + 'get_summary_combine_archive_log', +] def init_combine_archive_log(archive, archive_dir, - supported_features=(CombineArchive, SedDocument, Task, Report)): - """ Initialize the execution status of the SED documents in a COMBINE/OMEX archive + supported_features=(SedDocument, Task, Report, Plot2D, Plot3D, DataSet, Curve, Surface), + logged_features=(SedDocument, Task, Report, Plot2D, Plot3D, DataSet, Curve, Surface)): + """ Initialize a log of a COMBINE/OMEX archive Args: archive (:obj:`CombineArchive`): COMBINE/OMEX archive archive_dir (:obj:`str`): path where the content of the archive is located - supported_features (:obj:`list` of :obj:`type`, optional): list of supported SED elements. - Default: COMBINE/OMEX archives, SED documents, SED tasks, and SED reports + supported_features (:obj:`list` of :obj:`type`, optional): list of supported elements. + Default: COMBINE/OMEX archives and SED documents, tasks, reports, plots, + data sets, curves, and surfaces. + logged_features (:obj:`list` of :obj:`type`, optional): list of elements which + will be logged. Default: COMBINE/OMEX archives and SED documents, tasks, reports, plots, + data sets, curves, and surfaces. Returns: - :obj:`CombineArchiveLog`: initial execution status of the SED documents in an archive + :obj:`CombineArchiveLog`: initialized log of a COMBINE/OMEX archive """ contents = get_sedml_contents(archive, include_non_executing_docs=False) - status_value = Status.QUEUED if isinstance(archive, supported_features) else Status.SKIPPED - status = CombineArchiveLog(status=status_value) - for content in contents: - content_filename = os.path.join(archive_dir, content.location) - doc = SedmlSimulationReader().run(content_filename) + log = CombineArchiveLog(status=Status.QUEUED) - doc_id = os.path.relpath(content_filename, archive_dir) + if SedDocument in logged_features: + log.sed_documents = {} + for content in contents: + content_filename = os.path.join(archive_dir, content.location) + doc = SedmlSimulationReader().run(content_filename) - doc_status_value = Status.QUEUED if isinstance(doc, supported_features) else Status.SKIPPED - doc_status = SedDocumentLog(status=doc_status_value, combine_archive_status=status) - status.sed_documents[doc_id] = doc_status + doc_log = init_sed_document_log(doc, supported_features=supported_features, logged_features=logged_features) + doc_log.status = Status.QUEUED if isinstance(doc, supported_features) else Status.SKIPPED + doc_log.parent = log + doc_id = os.path.relpath(content_filename, archive_dir) + log.sed_documents[doc_id] = doc_log + + else: + log.sed_documents = None + + return log + + +def init_sed_document_log(doc, + supported_features=(Task, Report, Plot2D, Plot3D, DataSet, Curve, Surface), + logged_features=(Task, Report, Plot2D, Plot3D, DataSet, Curve, Surface)): + """ Initialize a log of a SED document + + Args: + doc (:obj:`SedDocument`): SED document + supported_features (:obj:`list` of :obj:`type`, optional): list of supported elements. + Default: tasks, reports, plots, data sets, curves, and surfaces. + logged_features (:obj:`list` of :obj:`type`, optional): list of SED elements which + will be logged. Default: tasks, reports, plots, data sets, curves, and surfaces. + + Returns: + :obj:`SedDocumentLog`: initialized log of a SED document + """ + log = SedDocumentLog() + + if Task in logged_features: + log.tasks = {} for task in doc.tasks: - el_status_value = Status.QUEUED if isinstance(task, supported_features) else Status.SKIPPED - task_status = TaskLog(status=el_status_value, document_status=doc_status) - doc_status.tasks[task.id] = task_status + task_log = init_task_log(task, supported_features=supported_features, logged_features=logged_features) + task_log.status = Status.QUEUED if isinstance(task, supported_features) else Status.SKIPPED + task_log.parent = log + log.tasks[task.id] = task_log + else: + log.tasks = None + + if set([Output, Report, Plot2D, Plot3D]).intersection(logged_features): + log.outputs = {} for output in doc.outputs: - output_status_value = Status.QUEUED if isinstance(output, supported_features) else Status.SKIPPED + if isinstance(output, logged_features): + output_log = init_output_log(output, supported_features=supported_features, logged_features=logged_features) + output_log.status = Status.QUEUED if isinstance(output, supported_features) else Status.SKIPPED + output_log.parent = log + else: + output_log = None + log.outputs[output.id] = output_log + else: + log.outputs = None - if isinstance(output, Report): - output_status = ReportLog(status=output_status_value, document_status=doc_status) - for data_set in output.data_sets: - output_status.data_sets[data_set.id] = output_status_value + return log - elif isinstance(output, Plot2D): - output_status = Plot2DLog(status=output_status_value, document_status=doc_status) - for curve in output.curves: - output_status.curves[curve.id] = output_status_value - elif isinstance(output, Plot3D): - output_status = Plot3DLog(status=output_status_value, document_status=doc_status) - for surface in output.surfaces: - output_status.surfaces[surface.id] = output_status_value +def init_task_log(task, + supported_features=(), + logged_features=()): + """ Initialize a log of a task - else: - raise NotImplementedError() # pragma: no cover # uncreachable + Args: + output (:obj:`Task`): a SED task + supported_features (:obj:`list` of :obj:`type`, optional): list of supported elements. + Default: empty list. + logged_features (:obj:`list` of :obj:`type`, optional): list of elements which + will be logged. Default: empty list. + + Returns: + :obj:`OutputLog`: initialized log of a SED document + """ + return TaskLog() + + +def init_output_log(output, + supported_features=(DataSet, Curve, Surface), + logged_features=(DataSet, Curve, Surface)): + """ Initialize a log of an output + + Args: + output (:obj:`Output`): a SED output + supported_features (:obj:`list` of :obj:`type`, optional): list of supported elements. + Default: data sets, curves, and surfaces. + logged_features (:obj:`list` of :obj:`type`, optional): list of elements which + will be logged. Default: data sets, curves, and surfaces. + + Returns: + :obj:`OutputLog`: initialized log of a SED document + """ + + if isinstance(output, Report): + log = init_report_log(output, supported_features=supported_features, logged_features=logged_features) + + elif isinstance(output, Plot2D): + log = init_plot2d_log(output, supported_features=supported_features, logged_features=logged_features) + + elif isinstance(output, Plot3D): + log = init_plot3d_log(output, supported_features=supported_features, logged_features=logged_features) + + else: + raise NotImplementedError('`{}` outputs are not supported.'.format( + output.__class__.__name__)) # pragma: no cover # unreachable because all cases are enumerated above + + return log + + +def init_report_log(report, + supported_features=(DataSet, Curve, Surface), + logged_features=(DataSet, Curve, Surface)): + """ Initialize a log of a report + + Args: + report (:obj:`Report`): a SED report + supported_features (:obj:`list` of :obj:`type`, optional): list of supported elements. + Default: data sets. + logged_features (:obj:`list` of :obj:`type`, optional): list of elements which + will be logged. Default: data sets. + + Returns: + :obj:`ReportLog`: initialized log of a report + """ + + log = ReportLog() + + if DataSet in logged_features: + log.data_sets = {} + for data_set in report.data_sets: + log.data_sets[data_set.id] = ( + Status.QUEUED + if isinstance(data_set, supported_features) + else Status.SKIPPED) + else: + log.data_sets = None + + return log + + +def init_plot2d_log(plot, + supported_features=(Curve), + logged_features=(Curve)): + """ Initialize a log of a 2D plot + + Args: + plot (:obj:`Plot2D`): a SED 2D plot + supported_features (:obj:`list` of :obj:`type`, optional): list of supported elements. + Default: curves. + logged_features (:obj:`list` of :obj:`type`, optional): list of elements which + will be logged. Default: curves. + + Returns: + :obj:`Plot2DLog`: initialized log of a 2D plot + """ + log = Plot2DLog() + + if Curve in logged_features: + log.curves = {} + for curve in plot.curves: + log.curves[curve.id] = ( + Status.QUEUED + if isinstance(curve, supported_features) + else Status.SKIPPED) + else: + log.curves = None + + return log + + +def init_plot3d_log(plot, + supported_features=(Surface), + logged_features=(Surface)): + """ Initialize a log of a 3D plot + + Args: + plot (:obj:`Plot3D`): a SED 3D plot + supported_features (:obj:`list` of :obj:`type`, optional): list of supported elements. + Default: surfaces. + logged_features (:obj:`list` of :obj:`type`, optional): list of elements which + will be logged. Default: surfaces. + + Returns: + :obj:`Plot3DLog`: initialized log of a 3D plot + """ + log = Plot3DLog() + + if Surface in logged_features: + log.surfaces = {} + for surface in plot.surfaces: + log.surfaces[surface.id] = ( + Status.QUEUED + if isinstance(surface, supported_features) + else Status.SKIPPED) + else: + log.surfaces = None + + return log + + +def get_summary_combine_archive_log(log): + """ Get a summary of the log of a COMBINE/OMEX archive + + Args: + log (:obj:`CombineArchiveLog`): log of a COMBINE/OMEX archive + + Returns: + :obj:`str`: summary of the log + """ + tasks_logged = False + outputs_logged = False + + n_archives = 0 + n_tasks = 0 + n_outputs = 0 + + archive_status_count = { + Status.SUCCEEDED: 0, + Status.SKIPPED: 0, + Status.FAILED: 0, + None: 0, + } + task_status_count = { + Status.SUCCEEDED: 0, + Status.SKIPPED: 0, + Status.FAILED: 0, + None: 0, + } + output_status_count = { + Status.SUCCEEDED: 0, + Status.SKIPPED: 0, + Status.FAILED: 0, + None: 0, + } + for doc_log in log.sed_documents.values(): + n_archives += 1 + archive_status_count[doc_log.status] += 1 + if doc_log.tasks is not None: + tasks_logged = True + for task_log in doc_log.tasks.values(): + n_tasks += 1 + task_status_count[task_log.status if task_log else None] += 1 + if doc_log.outputs is not None: + outputs_logged = True + for output_log in doc_log.outputs.values(): + n_outputs += 1 + output_status_count[output_log.status if output_log else None] += 1 + + msg = '' + msg += 'Executed {} COMBINE/OMEX archives:\n'.format(n_archives) + msg += ' Archives ({}):\n'.format(n_archives) + msg += ' Succeeded: {}\n'.format(archive_status_count[Status.SUCCEEDED]) + msg += ' Skipped: {}\n'.format(archive_status_count[Status.SKIPPED]) + msg += ' Failed: {}\n'.format(archive_status_count[Status.FAILED]) + + if tasks_logged: + msg += ' Tasks ({}):\n'.format(n_tasks) + msg += ' Succeeded: {}\n'.format(task_status_count[Status.SUCCEEDED]) + msg += ' Skipped: {}\n'.format(task_status_count[Status.SKIPPED]) + msg += ' Failed: {}\n'.format(task_status_count[Status.FAILED]) + if task_status_count[None]: + msg += ' Unknown: {}\n'.format(task_status_count[None]) - doc_status.outputs[output.id] = output_status + if outputs_logged: + msg += ' Outputs ({}):\n'.format(n_outputs) + msg += ' Succeeded: {}\n'.format(output_status_count[Status.SUCCEEDED]) + msg += ' Skipped: {}\n'.format(output_status_count[Status.SKIPPED]) + msg += ' Failed: {}\n'.format(output_status_count[Status.FAILED]) + if output_status_count[None]: + msg += ' Unknown: {}\n'.format(output_status_count[None]) - return status + return msg diff --git a/biosimulators_utils/report/io.py b/biosimulators_utils/report/io.py index 638bf1dc..9ebdb621 100644 --- a/biosimulators_utils/report/io.py +++ b/biosimulators_utils/report/io.py @@ -12,6 +12,7 @@ import os import pandas import tables +import warnings __all__ = [ 'ReportWriter', @@ -52,14 +53,17 @@ def run(self, results, base_path, rel_path, format=ReportFormat.h5): filename = os.path.join(base_path, get_config().H5_REPORTS_PATH) if not os.path.isdir(base_path): os.makedirs(base_path) - results.to_hdf(filename, - key=rel_path, - format='table', - complevel=9, - complib='zlib', - mode='a', - append=False, - ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", tables.NaturalNameWarning) + results.to_hdf(filename, + key=rel_path, + format='table', + complevel=9, + complib='zlib', + mode='a', + append=False, + ) else: raise NotImplementedError('Report format {} is not supported'.format(format)) diff --git a/biosimulators_utils/sedml/exceptions.py b/biosimulators_utils/sedml/exceptions.py new file mode 100644 index 00000000..67197331 --- /dev/null +++ b/biosimulators_utils/sedml/exceptions.py @@ -0,0 +1,18 @@ +""" Exceptions for SED-ML + +:Author: Jonathan Karr +:Date: 2021-01-12 +:Copyright: 2021, Center for Reproducible Biomedical Modeling +:License: MIT +""" + +from ..exceptions import BioSimulatorsException + +__all__ = [ + 'SedmlExecutionError', +] + + +class SedmlExecutionError(BioSimulatorsException): + """ Error that a SED document could not be executed """ + pass # pragma: no cover diff --git a/biosimulators_utils/sedml/exec.py b/biosimulators_utils/sedml/exec.py index 0361e783..f71a3916 100644 --- a/biosimulators_utils/sedml/exec.py +++ b/biosimulators_utils/sedml/exec.py @@ -6,23 +6,29 @@ :License: MIT """ -from ..config import get_config -from ..log.data_model import Status, SedDocumentLog # noqa: F401 +from ..config import get_config, Colors +from ..log.data_model import Status, SedDocumentLog, ReportLog, Plot2DLog, Plot3DLog # noqa: F401 +from ..log.utils import init_sed_document_log from ..plot.data_model import PlotFormat -from ..report.data_model import DataGeneratorVariableResults, DataGeneratorResults, OutputResults, ReportFormat +from ..report.data_model import DataGeneratorVariableResults, OutputResults, ReportFormat from ..report.io import ReportWriter +from ..warnings import warn from .data_model import SedDocument, Task, Report, Plot2D, Plot3D +from .exceptions import SedmlExecutionError from .warnings import RepeatDataSetLabelsWarning, SedmlFeatureNotSupportedWarning from .io import SedmlSimulationReader from .utils import resolve_model, apply_changes_to_xml_model, get_variables_for_task, calc_data_generator_results from .warnings import NoTasksWarning, NoOutputsWarning +import capturer import copy +import datetime import numpy import os import pandas +import sys +import termcolor import tempfile import types # noqa: F401 -import warnings __all__ = [ @@ -54,24 +60,29 @@ def exec_task(task, variables): doc (:obj:`SedDocument` or :obj:`str`): SED document or a path to SED-ML file which defines a SED document working_dir (:obj:`str`): working directory of the SED document (path relative to which models are located) - out_path (:obj:`str`): path to store the outputs + base_out_path (:obj:`str`): path to store the outputs * CSV: directory in which to save outputs to files - ``{out_path}/{rel_out_path}/{report.id}.csv`` - * HDF5: directory in which to save a single HDF5 file (``{out_path}/reports.h5``), + ``{base_out_path}/{rel_out_path}/{report.id}.csv`` + * HDF5: directory in which to save a single HDF5 file (``{base_out_path}/reports.h5``), with reports at keys ``{rel_out_path}/{report.id}`` within the HDF5 file - rel_out_path (:obj:`str`, optional): path relative to :obj:`out_path` to store the outputs + rel_out_path (:obj:`str`, optional): path relative to :obj:`base_out_path` to store the outputs apply_xml_model_changes (:obj:`bool`, optional): if :obj:`True`, apply any model changes specified in the SED-ML file before calling :obj:`task_executer`. report_formats (:obj:`list` of :obj:`ReportFormat`, optional): report format (e.g., csv or h5) plot_formats (:obj:`list` of :obj:`PlotFormat`, optional): plot format (e.g., pdf) - log (:obj:`SedDocumentLog`, optional): execution status of document + log (:obj:`SedDocumentLog`, optional): log of the document indent (:obj:`int`, optional): degree to indent status messages Returns: - :obj:`OutputResults`: results of each report + :obj:`tuple`: + + * :obj:`OutputResults`: results of each report + * :obj:`SedDocumentLog`: log of the document """ + config = get_config() + # process arguments if not isinstance(doc, SedDocument): doc = SedmlSimulationReader().run(doc) @@ -79,194 +90,331 @@ def exec_task(task, variables): doc = copy.deepcopy(doc) if report_formats is None: - report_formats = [ReportFormat(format_value) for format_value in get_config().REPORT_FORMATS] + report_formats = [ReportFormat(format_value) for format_value in config.REPORT_FORMATS] if plot_formats is None: - plot_formats = [PlotFormat(format_value) for format_value in get_config().PLOT_FORMATS] - - # update status - if log: - log.status = Status.RUNNING - log.export() + plot_formats = [PlotFormat(format_value) for format_value in config.PLOT_FORMATS] - # apply changes to models - modified_model_filenames = [] - for model in doc.models: - resolve_model(model, doc, working_dir) + log = log or init_sed_document_log(doc) - if apply_xml_model_changes and model.changes: - original_model_filename = model.source + verbose = config.VERBOSE - modified_model_file, modified_model_filename = tempfile.mkstemp(suffix='.xml') - os.close(modified_model_file) - - apply_changes_to_xml_model(model.changes, original_model_filename, modified_model_filename) - - model.source = modified_model_filename - - modified_model_filenames.append(modified_model_filename) + # update status + exceptions = [] # execute tasks if not doc.tasks: - warnings.warn('SED document does not describe any tasks', NoTasksWarning) + warn('SED document does not describe any tasks.', NoTasksWarning) # TODO: initialize reports with their eventual shapes; this requires individual simulation tools to pass # information about the shape of their output to this method variable_results = DataGeneratorVariableResults() - data_gen_results = DataGeneratorResults() report_results = OutputResults() doc.tasks.sort(key=lambda task: task.id) - print('{}Found {} tasks\n{}{}'.format(' ' * 2 * indent, - len(doc.tasks), - ' ' * 2 * (indent + 1), - ('\n' + ' ' * 2 * (indent + 1)).join([task.id for task in doc.tasks]))) + print('{}Found {} tasks and {} outputs:\n{}Tasks:\n{}{}\n{}Outputs:\n{}{}'.format( + ' ' * 2 * indent, + len(doc.tasks), + len(doc.outputs), + ' ' * 2 * (indent + 1), + ' ' * 2 * (indent + 2), + ('\n' + ' ' * 2 * (indent + 2)).join(['`' + task.id + '`' for task in doc.tasks]), + ' ' * 2 * (indent + 1), + ' ' * 2 * (indent + 2), + ('\n' + ' ' * 2 * (indent + 2)).join(['`' + output.id + '`' for output in doc.outputs]), + )) for i_task, task in enumerate(doc.tasks): - print('{}Executing task {}: {}'.format(' ' * 2 * indent, i_task + 1, task.id)) - - if log: - log.tasks[task.id].status = Status.RUNNING - log.tasks[task.id].export() - - if isinstance(task, Task): - # get a list of the variables that the task needs to record - task_vars = get_variables_for_task(doc, task) + print('{}Executing task {}: `{}`'.format(' ' * 2 * indent, i_task + 1, task.id)) + + task_log = log.tasks[task.id] + task_log.status = Status.RUNNING + task_log.export() + + # Execute task + print('{}Executing simulation ...'.format(' ' * 2 * (indent + 1)), end='') + sys.stdout.flush() + with capturer.CaptureOutput(merged=True, relay=verbose) as captured: + start_time = datetime.datetime.now() + try: + if isinstance(task, Task): + # resolve model + original_model = task.model + model = copy.deepcopy(original_model) + task.model = model + is_model_source_temp = resolve_model(model, doc, working_dir) + + # apply changes to model + unmodified_model_filename = model.source + if apply_xml_model_changes and model.changes: + modified_model_file, modified_model_filename = tempfile.mkstemp(suffix='.xml') + os.close(modified_model_file) + + apply_changes_to_xml_model(model.changes, unmodified_model_filename, modified_model_filename) + + model.source = modified_model_filename + else: + modified_model_filename = None + + # get a list of the variables that the task needs to record + task_vars = get_variables_for_task(doc, task) + + # execute task + task_variable_results, _ = task_executer(task, task_vars, log=task_log) + + # check that the expected variables were recorded + missing_vars = [] + for var in task_vars: + variable_results[var.id] = task_variable_results.get(var.id, None) + if variable_results[var.id] is None: + missing_vars.append(var.id) + if missing_vars: + msg = 'Task `{}` did not generate the following expected variables:\n - {}'.format( + task.id, '\n - '.join('`' + var + '`' for var in sorted(missing_vars))) + raise ValueError(msg) + + # cleanup modified models + task.model = original_model + if is_model_source_temp: + os.remove(unmodified_model_filename) + if modified_model_filename: + os.remove(modified_model_filename) - # execute task and record variables - task_variable_results = task_executer(task, task_vars) + else: + raise NotImplementedError('Tasks of type {} are not supported.'.format(task.__class__.__name__)) + + task_status = Status.SUCCEEDED + task_exception = None + except Exception as exception: + exceptions.append(exception) + task_status = Status.FAILED + task_exception = exception + + if task_log: + task_log.status = task_status + task_log.exception = task_exception + task_log.output = captured.get_bytes().decode() + task_log.duration = (datetime.datetime.now() - start_time).total_seconds() + task_log.export() + print(' ' + termcolor.colored(task_status.value.lower(), Colors[task_status.value.lower()].value)) + + # generate outputs + print('{}Generating {} outputs ...'.format(' ' * 2 * (indent + 1), len(doc.outputs))) + task_contributes_to_output = False + for i_output, output in enumerate(doc.outputs): + print('{}Generating output {}: `{}` ...'.format(' ' * 2 * (indent + 2), i_output + 1, output.id), end='') + sys.stdout.flush() + start_time = datetime.datetime.now() + with capturer.CaptureOutput(merged=True, relay=verbose) as captured: + try: + if log.outputs[output.id].status == Status.SUCCEEDED: + continue + + if isinstance(output, Report): + report_results[output.id], output_status, task_contributes_to_report = exec_report( + output, variable_results, + base_out_path, rel_out_path, report_formats, + task, + log.outputs[output.id]) + task_contributes_to_output = task_contributes_to_output or task_contributes_to_report + + elif isinstance(output, Plot2D): + output_status = Status.SKIPPED + warn('Output {} skipped because outputs of type {} are not yet supported.'.format( + output.id, output.__class__.__name__), SedmlFeatureNotSupportedWarning) + # write_plot_2d() + + elif isinstance(output, Plot3D): + output_status = Status.SKIPPED + warn('Output {} skipped because outputs of type {} are not yet supported.'.format( + output.id, output.__class__.__name__), SedmlFeatureNotSupportedWarning) + # write_plot_3d() - for var in task_vars: - variable_results[var.id] = task_variable_results.get(var.id, None) - if variable_results[var.id] is None: - raise ValueError('Variable {} must be generated for task {}'.format(var.id, task.id)) + else: + # unreachable because the above cases cover all types of outputs + raise NotImplementedError('Outputs of type {} are not supported.'.format(output.__class__.__name__)) + + output_exception = None + + except Exception as exception: + exceptions.append(exception) + output_status = Status.FAILED + output_exception = exception + + log.outputs[output.id].status = output_status + log.outputs[output.id].exception = output_exception + log.outputs[output.id].output = captured.get_bytes().decode() + log.outputs[output.id].duration = (datetime.datetime.now() - start_time).total_seconds() + log.outputs[output.id].export() + + print(' ' + termcolor.colored(output_status.value.lower(), Colors[output_status.value.lower()].value)) + + if not task_contributes_to_output: + warn('Task {} does not contribute to any outputs.'.format(task.id), NoOutputsWarning) + + # finalize the status of the outputs + for output_log in log.outputs.values(): + output_log.finalize() + + # summarize execution + task_status_count = { + Status.SUCCEEDED: 0, + Status.SKIPPED: 0, + Status.FAILED: 0, + } + for task_log in log.tasks.values(): + task_status_count[task_log.status] += 1 + + output_status_count = { + Status.SUCCEEDED: 0, + Status.SKIPPED: 0, + Status.FAILED: 0, + } + for output_log in log.outputs.values(): + output_status_count[output_log.status] += 1 + + print('') + print('{}Executed {} tasks and {} outputs:'.format(' ' * 2 * indent, len(doc.tasks), len(doc.outputs))) + print('{} Tasks:'.format(' ' * 2 * indent)) + print('{} Succeeded: {}'.format(' ' * 2 * indent, task_status_count[Status.SUCCEEDED])) + print('{} Skipped: {}'.format(' ' * 2 * indent, task_status_count[Status.SKIPPED])) + print('{} Failed: {}'.format(' ' * 2 * indent, task_status_count[Status.FAILED])) + print('{} Outputs:'.format(' ' * 2 * indent)) + print('{} Succeeded: {}'.format(' ' * 2 * indent, output_status_count[Status.SUCCEEDED])) + print('{} Skipped: {}'.format(' ' * 2 * indent, output_status_count[Status.SKIPPED])) + print('{} Failed: {}'.format(' ' * 2 * indent, output_status_count[Status.FAILED])) + + # raise exceptions + if exceptions: + msg = 'The SED document did not execute successfully:\n\n {}'.format( + '\n\n '.join(str(exceptions).replace('\n', '\n ') for exceptions in exceptions)) + raise SedmlExecutionError(msg) + + # return the results of the reports + return report_results, log + + +def exec_report(report, variable_results, base_out_path, rel_out_path, formats, task, log): + """ Execute a report, generating the datasets which are available - # calculate data generators - for data_gen in doc.data_generators: - vars_available = True - for variable in data_gen.variables: - if variable.id not in variable_results: - vars_available = False - break - if vars_available: - data_gen_results[data_gen.id] = calc_data_generator_results(data_gen, variable_results) - - # generate outputs - has_outputs = False - - for output in doc.outputs: - if log and log.outputs[output.id].status == Status.SUCCEEDED: - continue - - running = False - succeeded = True - - if isinstance(output, Report): - dataset_labels = [] - dataset_results = [] - dataset_shapes = set() - - for data_set in output.data_sets: - if next((True for var in data_set.data_generator.variables if var.task == task), False): - has_outputs = True - - dataset_labels.append(data_set.label) - data_gen_res = data_gen_results.get(data_set.data_generator.id, None) - dataset_results.append(data_gen_res) - if data_gen_res is None: - succeeded = False - else: - running = True - dataset_shapes.add(data_gen_res.shape) - if log: - log.outputs[output.id].data_sets[data_set.id] = Status.SUCCEEDED - - if len(dataset_shapes) > 1: - warnings.warn('Data generators for report {} do not have consistent shapes'.format(output.id), UserWarning) - - if len(set(dataset_labels)) < len(dataset_labels): - warnings.warn('To facilitate machine interpretation, data sets should have unique ids', - RepeatDataSetLabelsWarning) - - dataset_max_shape = [] - for dataset_shape in dataset_shapes: - dataset_max_shape = dataset_max_shape + [1] * (len(dataset_shape) - len(dataset_max_shape)) - dataset_shape = list(dataset_shape) + [1] * (len(dataset_shape) - len(dataset_max_shape)) - dataset_max_shape = [max(x, y) for x, y in zip(dataset_max_shape, dataset_shape)] - - for i_result, dataset_result in enumerate(dataset_results): - if dataset_result is None: - dataset_results[i_result] = numpy.full(dataset_shape, numpy.nan) - - dataset_shape = tuple(list(dataset_results[i_result].shape) - + [1] * (len(dataset_max_shape) - dataset_results[i_result].ndim)) - dataset_results[i_result].reshape(dataset_shape) - - pad_width = tuple((0, x - y) for x, y in zip(dataset_max_shape, dataset_shape)) - if pad_width: - dataset_results[i_result] = numpy.pad(dataset_results[i_result], - pad_width, - mode='constant', - constant_values=numpy.nan) - - output_df = pandas.DataFrame(numpy.array(dataset_results), index=dataset_labels) - report_results[output.id] = output_df - - for report_format in report_formats: - ReportWriter().run(output_df, - base_out_path, - os.path.join(rel_out_path, output.id) if rel_out_path else output.id, - format=report_format) - - elif isinstance(output, Plot2D): - for curve in output.curves: - if next((True for var in curve.x_data_generator.variables if var.task == task), False): - has_outputs = True - if next((True for var in curve.y_data_generator.variables if var.task == task), False): - has_outputs = True - - warnings.warn('Output {} skipped because outputs of type {} are not yet supported'.format( - output.id, output.__class__.__name__), SedmlFeatureNotSupportedWarning) - # write_plot_2d() - - elif isinstance(output, Plot3D): - for surface in output.surfaces: - if next((True for var in surface.x_data_generator.variables if var.task == task), False): - has_outputs = True - if next((True for var in surface.y_data_generator.variables if var.task == task), False): - has_outputs = True - if next((True for var in surface.z_data_generator.variables if var.task == task), False): - has_outputs = True - - warnings.warn('Output {} skipped because outputs of type {} are not yet supported'.format( - output.id, output.__class__.__name__), SedmlFeatureNotSupportedWarning) - # write_plot_3d() + Args: + report (:obj:`Report`): report + variable_results (:obj:`DataGeneratorVariableResults`): result of each data generator + base_out_path (:obj:`str`): path to store the outputs - else: - raise NotImplementedError('Outputs of type {} are not supported'.format(output.__class__.__name__)) + * CSV: directory in which to save outputs to files + ``{base_out_path}/{rel_out_path}/{report.id}.csv`` + * HDF5: directory in which to save a single HDF5 file (``{base_out_path}/reports.h5``), + with reports at keys ``{rel_out_path}/{report.id}`` within the HDF5 file - if not has_outputs: - warnings.warn('Task {} does not contribute to any outputs'.format(task.id), NoOutputsWarning) + rel_out_path (:obj:`str`, optional): path relative to :obj:`base_out_path` to store the outputs + formats (:obj:`list` of :obj:`ReportFormat`, optional): report format (e.g., csv or h5) + task (:obj:`Task`): task + log (:obj:`ReportLog`, optional): log of report - if running and log: - if succeeded: - log.outputs[output.id].status = Status.SUCCEEDED - else: - log.outputs[output.id].status = Status.RUNNING + Returns: + :obj:`tuple`: + * :obj:`pandas.DataFrame`: report + * :obj:`Status`: status + * :obj:`bool`: whether :obj:`task` contribute a variable to the report + """ + dataset_labels = [] + dataset_results = [] + dataset_shapes = set() + + task_contributes_to_report = False + running = False + succeeded = True + failed = False + + # calculate data generators + data_gen_results = {} + for data_set in report.data_sets: + dataset_labels.append(data_set.label) + + data_gen = data_set.data_generator + if data_gen.id in data_gen_results: + data_gen_res = data_gen_results[data_gen.id] else: - raise NotImplementedError('Tasks of type {} are not supported'.format(task.__class__.__name__)) - - if log: - log.tasks[task.id].status = Status.SUCCEEDED - log.tasks[task.id].export() - - # cleanup modified models - for modified_model_filename in modified_model_filenames: - os.remove(modified_model_filename) + vars_available = True + vars_failed = False + for variable in data_gen.variables: + if variable.task == task: + task_contributes_to_report = True + if variable.id in variable_results: + if variable_results.get(variable.id, None) is None: + vars_available = False + vars_failed = True + else: + vars_available = False + + if vars_available and not vars_failed: + data_gen_res = calc_data_generator_results(data_gen, variable_results) + else: + data_gen_res = None + data_gen_results[data_gen.id] = data_gen_res + + dataset_results.append(data_gen_res) + if data_gen_res is None: + if vars_failed: + failed = True + log.data_sets[data_set.id] = Status.FAILED + succeeded = False + else: + running = True + data_set_shape = data_gen_res.shape + if not data_set_shape and data_gen_res.size: + data_set_shape = (1,) + + dataset_shapes.add(data_set_shape) + log.data_sets[data_set.id] = Status.SUCCEEDED + + if len(dataset_shapes) > 1: + warn('Data generators for report {} do not have consistent shapes'.format(report.id), UserWarning) + + if len(set(dataset_labels)) < len(dataset_labels): + warn('To facilitate machine interpretation, data sets should have unique ids.', + RepeatDataSetLabelsWarning) + + dataset_max_shape = [] + for dataset_shape in dataset_shapes: + dataset_max_shape = dataset_max_shape + [1 if dataset_max_shape else 0] * (len(dataset_shape) - len(dataset_max_shape)) + dataset_shape = list(dataset_shape) + [1 if dataset_shape else 0] * (len(dataset_shape) - len(dataset_max_shape)) + dataset_max_shape = [max(x, y) for x, y in zip(dataset_max_shape, dataset_shape)] + + for i_result, dataset_result in enumerate(dataset_results): + if dataset_result is None: + dataset_results[i_result] = numpy.full(dataset_max_shape, numpy.nan) + + dataset_shape = tuple(list(dataset_results[i_result].shape) + + [1 if dataset_results[i_result].size else 0] + * (len(dataset_max_shape) - dataset_results[i_result].ndim)) + dataset_results[i_result].reshape(dataset_shape) + + pad_width = tuple((0, x - y) for x, y in zip(dataset_max_shape, dataset_shape)) + if pad_width: + dataset_results[i_result] = numpy.pad(dataset_results[i_result], + pad_width, + mode='constant', + constant_values=numpy.nan) + + output_df = pandas.DataFrame(numpy.array(dataset_results), index=dataset_labels) + for format in formats: + ReportWriter().run(output_df, + base_out_path, + os.path.join(rel_out_path, report.id) if rel_out_path else report.id, + format=format) + + if failed: + status = Status.FAILED + + elif running: + if succeeded: + status = Status.SUCCEEDED + else: + status = Status.RUNNING - # update status - if log: - log.status = Status.SUCCEEDED - log.export() + else: + status = Status.QUEUED - return report_results + return output_df, status, task_contributes_to_report diff --git a/biosimulators_utils/sedml/io.py b/biosimulators_utils/sedml/io.py index 223cc40d..6ce00442 100644 --- a/biosimulators_utils/sedml/io.py +++ b/biosimulators_utils/sedml/io.py @@ -12,11 +12,11 @@ from ..biosimulations.data_model import Metadata, ExternalReferences, Citation from ..data_model import Person, Identifier, OntologyTerm from ..kisao.utils import normalize_kisao_id +from ..warnings import warn from xml.sax import saxutils import dateutil.parser import enum import libsedml -import warnings __all__ = [ 'SedmlSimulationReader', @@ -785,8 +785,8 @@ def run(self, filename, validate_semantics=True): # data descriptions if doc_sed.getListOfDataDescriptions(): - warnings.warn('Data descriptions skipped because data descriptions are not yet supported', - SedmlFeatureNotSupportedWarning) + warn('Data descriptions skipped because data descriptions are not yet supported', + SedmlFeatureNotSupportedWarning) # models id_to_model_map = {} diff --git a/biosimulators_utils/sedml/utils.py b/biosimulators_utils/sedml/utils.py index ec29302f..7089f3fe 100644 --- a/biosimulators_utils/sedml/utils.py +++ b/biosimulators_utils/sedml/utils.py @@ -7,10 +7,12 @@ """ from ..report.data_model import DataGeneratorVariableResults # noqa: F401 +from ..warnings import warn from .data_model import (SedDocument, Model, ModelChange, ModelAttributeChange, AddElementModelChange, # noqa: F401 ReplaceElementModelChange, RemoveElementModelChange, ComputeModelChange, Task, Report, Plot2D, Plot3D, DataGenerator, DataGeneratorVariable, MATHEMATICAL_FUNCTIONS) +from .warnings import InconsistentVariableShapesWarning from lxml import etree import copy import evalidate @@ -121,6 +123,9 @@ def resolve_model(model, sed_doc, working_dir): sed_doc (:obj:`SedDocument`): parent SED document; used to resolve sources defined by reference to other models working_dir (:obj:`str`): working directory of the SED document (path relative to which models are located) + + Returns: + :obj:`bool`: whether the model source is a temporary file obtained from an external source """ source = model.source @@ -141,6 +146,8 @@ def resolve_model(model, sed_doc, working_dir): else: raise NotImplementedError('URN model source `{}` could be resolved.'.format(source)) + return True + elif re.match(r'^http(s)?://', source, re.IGNORECASE): response = requests.get(source) try: @@ -153,6 +160,8 @@ def resolve_model(model, sed_doc, working_dir): with open(model.source, 'wb') as file: file.write(response.content) + return True + elif source.startswith('#'): other_model_id = source[1:] other_model = next((m for m in sed_doc.models if m.id == other_model_id), None) @@ -161,7 +170,7 @@ def resolve_model(model, sed_doc, working_dir): model.source = other_model.source model.changes = other_model.changes + model.changes - model = resolve_model(model, sed_doc, working_dir) + return resolve_model(model, sed_doc, working_dir) else: if os.path.isabs(source): @@ -172,7 +181,7 @@ def resolve_model(model, sed_doc, working_dir): if not os.path.isfile(model.source): raise FileNotFoundError('Model source file `{}` does not exist.'.format(source)) - return model + return False def apply_changes_to_xml_model(changes, in_model_filename, out_model_filename, pretty_print=False): @@ -269,12 +278,21 @@ def calc_data_generator_results(data_generator, variable_results): :obj:`numpy.ndarray`: result of data generator """ var_shapes = set() + max_shape = [] for var in data_generator.variables: var_res = variable_results[var.id] - var_shapes.add(var_res.shape) + var_shape = var_res.shape + if not var_shape and var_res.size: + var_shape = (1,) + var_shapes.add(var_shape) + + max_shape = max_shape + [1 if max_shape else 0] * (var_res.ndim - len(max_shape)) + for i_dim in range(var_res.ndim): + max_shape[i_dim] = max(max_shape[i_dim], var_res.shape[i_dim]) if len(var_shapes) > 1: - raise ValueError('Variables for data generator {} must have consistent shapes'.format(data_generator.id)) + warn('Variables for data generator {} do not have consistent shapes'.format(data_generator.id), + InconsistentVariableShapesWarning) math_node = evalidate.evalidate(data_generator.math, addnodes=[ @@ -307,16 +325,42 @@ def calc_data_generator_results(data_generator, variable_results): result = numpy.array(value) else: - shape = list(var_shapes)[0] - result = numpy.full(shape, numpy.nan) + padded_var_shapes = [] + for var in data_generator.variables: + var_res = variable_results[var.id] + padded_var_shapes.append( + list(var_res.shape) + + [1 if var_res.size else 0] * (len(max_shape) - var_res.ndim) + ) + + result = numpy.full(max_shape, numpy.nan) n_dims = result.ndim for i_el in range(result.size): - for var in data_generator.variables: + el_indices = numpy.unravel_index(i_el, result.shape) + + vars_available = True + for var, padded_shape in zip(data_generator.variables, padded_var_shapes): var_res = variable_results[var.id] - if n_dims == 0: - workspace[var.id] = variable_results[var.id].tolist() + if var_res.ndim == 0: + if i_el == 0 and var_res.size: + workspace[var.id] = var_res.tolist() + else: + vars_available = False + break + else: - workspace[var.id] = variable_results[var.id].flat[i_el] + for x, y in zip(padded_shape, el_indices): + if (y + 1) > x: + vars_available = False + break + if not vars_available: + break + + workspace[var.id] = var_res[el_indices[0:var_res.ndim]] + + if not vars_available: + continue + try: result_el = eval(compiled_math, MATHEMATICAL_FUNCTIONS, workspace) except Exception as exception: diff --git a/biosimulators_utils/sedml/warnings.py b/biosimulators_utils/sedml/warnings.py index 8a12be25..bdb7125d 100644 --- a/biosimulators_utils/sedml/warnings.py +++ b/biosimulators_utils/sedml/warnings.py @@ -11,6 +11,7 @@ __all__ = [ 'RepeatDataSetLabelsWarning', 'IllogicalSedmlWarning', + 'InconsistentVariableShapesWarning', 'NoTasksWarning', 'NoOutputsWarning', 'NoDataSetsWarning', @@ -32,6 +33,11 @@ class IllogicalSedmlWarning(BioSimulatorsWarning): pass # pragma: no cover +class InconsistentVariableShapesWarning(BioSimulatorsWarning): + """ Warning that the variables of a data generator have different shapes. """ + pass # pragma: no cover + + class NoTasksWarning(IllogicalSedmlWarning): """ Warning that a SED document does not have any tasks """ pass diff --git a/biosimulators_utils/simulator/cli.py b/biosimulators_utils/simulator/cli.py index 53e4c9db..6dfa442f 100644 --- a/biosimulators_utils/simulator/cli.py +++ b/biosimulators_utils/simulator/cli.py @@ -10,6 +10,7 @@ import cement import platform import sys +import termcolor import types # noqa: F401 __all__ = [ @@ -131,7 +132,7 @@ def _default(self): try: combine_archive_executer(args.archive, args.out_dir) except Exception as exception: - raise SystemExit(str(exception)) from exception + raise SystemExit(termcolor.colored(str(exception), 'red')) from exception class App(cement.App): """ Command line application """ diff --git a/biosimulators_utils/warnings.py b/biosimulators_utils/warnings.py index 1c18acad..ba19ec47 100644 --- a/biosimulators_utils/warnings.py +++ b/biosimulators_utils/warnings.py @@ -6,11 +6,26 @@ :License: MIT """ +from .config import Colors +import termcolor +import warnings + __all__ = [ 'BioSimulatorsWarning', + 'warn', ] class BioSimulatorsWarning(UserWarning): """ Base class for simulator warnings """ pass # pragma: no cover + + +def warn(message, category): + """ Issue a warning in a color + + Args: + message (:obj:`str`): message + category (:obj:`type`): category + """ + warnings.warn(termcolor.colored(message, Colors.warning.value), category) diff --git a/requirements.txt b/requirements.txt index 09d3c4fa..a6b15b67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ requests seaborn simplejson tables +termcolor yamldown diff --git a/tests/combine/test_combine_exec.py b/tests/combine/test_combine_exec.py index c7e95413..2aa5af51 100644 --- a/tests/combine/test_combine_exec.py +++ b/tests/combine/test_combine_exec.py @@ -1,11 +1,12 @@ from biosimulators_utils.archive.io import ArchiveReader from biosimulators_utils.combine.data_model import CombineArchive, CombineArchiveContent +from biosimulators_utils.combine.exceptions import CombineArchiveExecutionError from biosimulators_utils.combine.exec import exec_sedml_docs_in_archive from biosimulators_utils.combine.io import CombineArchiveWriter from biosimulators_utils.combine.warnings import NoSedmlWarning from biosimulators_utils.plot.data_model import PlotFormat from biosimulators_utils.report.data_model import ReportFormat -from biosimulators_utils.sedml.data_model import SedDocument +from biosimulators_utils.sedml.data_model import SedDocument, Task, Report from biosimulators_utils.sedml.io import SedmlSimulationReader from unittest import mock import datetime @@ -52,8 +53,8 @@ def sed_task_executer(task, variables): out_dir = os.path.join(self.tmp_dir, 'outputs') def exec_sed_doc(task_executer, filename, working_dir, base_out_dir, - rel_path, apply_xml_model_changes=False, report_formats=None, plot_formats=None, - indent=0, log=None): + rel_path, apply_xml_model_changes=False, report_formats=None, plot_formats=None, + indent=0, log=None): out_dir = os.path.join(base_out_dir, rel_path) if not os.path.isdir(out_dir): os.makedirs(out_dir) @@ -65,7 +66,11 @@ def exec_sed_doc(task_executer, filename, working_dir, base_out_dir, file.write('DEF') with mock.patch('biosimulators_utils.sedml.exec.exec_sed_doc', side_effect=exec_sed_doc): - with mock.patch.object(SedmlSimulationReader, 'run', return_value=SedDocument()): + sed_doc = SedDocument( + tasks=[Task(id='task_1')], + outputs=[Report(id='output_1')], + ) + with mock.patch.object(SedmlSimulationReader, 'run', return_value=sed_doc): sed_doc_executer = functools.partial(exec_sed_doc, sed_task_executer) exec_sedml_docs_in_archive(sed_doc_executer, archive_filename, out_dir, report_formats=[ReportFormat.h5, ReportFormat.csv], @@ -86,6 +91,39 @@ def exec_sed_doc(task_executer, filename, working_dir, base_out_dir, plot_formats=[], bundle_outputs=True, keep_individual_outputs=True) + archive.contents[0].format = 'http://identifiers.org/combine.specifications/sed-ml' + CombineArchiveWriter().run(archive, in_dir, archive_filename) + out_dir = os.path.join(self.tmp_dir, 'outputs-with-error') + + def exec_sed_doc(task_executer, filename, working_dir, base_out_dir, + rel_path, apply_xml_model_changes=False, report_formats=None, plot_formats=None, + indent=0, log=None): + out_dir = os.path.join(base_out_dir, rel_path) + if not os.path.isdir(out_dir): + os.makedirs(out_dir) + with open(os.path.join(out_dir, 'report1.csv'), 'w') as file: + file.write('ABC') + with open(os.path.join(out_dir, 'report2.csv'), 'w') as file: + file.write('DEF') + with open(os.path.join(base_out_dir, 'reports.h5'), 'w') as file: + file.write('DEF') + raise ValueError('An error') + sed_doc = SedDocument( + tasks=[Task(id='task_1')], + outputs=[Report(id='output_1')], + ) + with mock.patch.object(SedmlSimulationReader, 'run', return_value=sed_doc): + sed_doc_executer = functools.partial(exec_sed_doc, sed_task_executer) + with self.assertRaisesRegex(CombineArchiveExecutionError, 'An error'): + exec_sedml_docs_in_archive(sed_doc_executer, archive_filename, out_dir, + report_formats=[ReportFormat.h5, ReportFormat.csv], + plot_formats=[], + bundle_outputs=True, keep_individual_outputs=True) + + self.assertEqual(sorted(os.listdir(out_dir)), sorted(['reports.h5', 'reports.zip', 'sim.sedml', 'log.yml'])) + self.assertEqual(sorted(os.listdir(os.path.join(out_dir, 'sim.sedml'))), + sorted(['report1.csv', 'report2.csv'])) + def test_2(self): updated = datetime.datetime(2020, 1, 2, 1, 2, 3, tzinfo=dateutil.tz.tzutc()) archive = CombineArchive( @@ -114,8 +152,8 @@ def sed_task_executer(task, variables): out_dir = os.path.join(self.tmp_dir, 'outputs') def exec_sed_doc(task_executer, filename, working_dir, base_out_dir, rel_path='.', - apply_xml_model_changes=False, report_formats=[ReportFormat.csv], plot_formats=[PlotFormat.pdf], - indent=0, log=None): + apply_xml_model_changes=False, report_formats=[ReportFormat.csv], plot_formats=[PlotFormat.pdf], + indent=0, log=None): out_dir = os.path.join(base_out_dir, rel_path) if not os.path.isdir(out_dir): os.makedirs(out_dir) @@ -184,39 +222,3 @@ def exec_sed_doc(task_executer, filename, working_dir, base_out_dir, rel_path='. sed_doc_executer = functools.partial(exec_sed_doc, sed_task_executer) exec_sedml_docs_in_archive(sed_doc_executer, archive_filename, out_dir) self.assertIn('log.yml', os.listdir(out_dir)) - - def test_error(self): - updated = datetime.datetime(2020, 1, 2, 1, 2, 3, tzinfo=dateutil.tz.tzutc()) - archive = CombineArchive( - contents=[ - CombineArchiveContent( - location='/dir1/dir2/sim.sedml', - format='http://identifiers.org/combine.specifications/sed-ml', - updated=updated, - ), - CombineArchiveContent( - location='model.xml', - format='http://identifiers.org/combine.specifications/sbml', - updated=updated, - ), - ], - updated=updated, - ) - - in_dir = os.path.join(self.tmp_dir, 'archive') - archive_filename = os.path.join(self.tmp_dir, 'archive.omex') - CombineArchiveWriter().run(archive, in_dir, archive_filename) - - def sed_task_executer(task, variables): - pass - - def exec_sed_doc(task_executer, filename, working_dir, base_out_dir, rel_path='.', - apply_xml_model_changes=False, report_formats=[ReportFormat.csv], plot_formats=[], - indent=0, log=None): - out_dir = os.path.join(base_out_dir, rel_path) - if not os.path.isdir(out_dir): - os.makedirs(out_dir) - with open(os.path.join(out_dir, 'report1.csv'), 'w') as file: - file.write('ABC') - with open(os.path.join(out_dir, 'report2.csv'), 'w') as file: - file.write('DEF') diff --git a/tests/log/test_log_data_model.py b/tests/log/test_log_data_model.py index 7d3f8b47..19c34e67 100644 --- a/tests/log/test_log_data_model.py +++ b/tests/log/test_log_data_model.py @@ -15,106 +15,153 @@ def tearDown(self): shutil.rmtree(self.dirname) def test(self): - 'CombineArchiveLog' - 'SedDocumentLog' + task_log = data_model.TaskLog( + status=data_model.Status.FAILED, + exception=ValueError('Big error'), + skip_reason=NotImplementedError('Skip rationale'), + output='Stdout/err', + duration=10.5, + algorithm='KISAO_0000019', + ) + self.assertEqual(task_log.to_dict(), { + 'status': 'FAILED', + 'exception': { + 'type': 'ValueError', + 'message': 'Big error', + }, + 'skipReason': { + 'type': 'NotImplementedError', + 'message': 'Skip rationale', + }, + 'output': 'Stdout/err', + 'duration': 10.5, + 'algorithm': 'KISAO_0000019', + 'simulatorDetails': None, + }) - task_status = data_model.TaskLog( + task_log = data_model.TaskLog( status=data_model.Status.RUNNING) - self.assertEqual(task_status.to_dict(), { + self.assertEqual(task_log.to_dict(), { 'status': 'RUNNING', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'algorithm': None, + 'simulatorDetails': None, }) - report_status = data_model.ReportLog( + report_log = data_model.ReportLog( status=data_model.Status.RUNNING, data_sets={ 'data_set_1': data_model.Status.QUEUED, 'data_set_2': data_model.Status.SUCCEEDED, }) - self.assertEqual(report_status.to_dict(), { + self.assertEqual(report_log.to_dict(), { 'status': 'RUNNING', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'dataSets': { 'data_set_1': 'QUEUED', 'data_set_2': 'SUCCEEDED', } }) - plot2d_status = data_model.Plot2DLog( + plot2d_log = data_model.Plot2DLog( status=data_model.Status.RUNNING, curves={ 'curve_1': data_model.Status.QUEUED, 'curve_2': data_model.Status.SUCCEEDED, }) - self.assertEqual(plot2d_status.to_dict(), { + self.assertEqual(plot2d_log.to_dict(), { 'status': 'RUNNING', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'curves': { 'curve_1': 'QUEUED', 'curve_2': 'SUCCEEDED', } }) - plot3d_status = data_model.Plot3DLog( + plot3d_log = data_model.Plot3DLog( status=data_model.Status.RUNNING, surfaces={ 'surface_1': data_model.Status.QUEUED, 'surface_2': data_model.Status.SUCCEEDED, }) - self.assertEqual(plot3d_status.to_dict(), { + self.assertEqual(plot3d_log.to_dict(), { 'status': 'RUNNING', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'surfaces': { 'surface_1': 'QUEUED', 'surface_2': 'SUCCEEDED', } }) - doc_status = data_model.SedDocumentLog( + doc_log = data_model.SedDocumentLog( status=data_model.Status.RUNNING, tasks={ - 'task_1': task_status, + 'task_1': task_log, }, outputs={ - 'report_1': report_status, - 'plot_1': plot2d_status, - 'plot_2': plot3d_status, + 'report_1': report_log, + 'plot_1': plot2d_log, + 'plot_2': plot3d_log, }, ) - self.assertEqual(doc_status.to_dict(), { + self.assertEqual(doc_log.to_dict(), { 'status': 'RUNNING', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_1': task_status.to_dict(), + 'task_1': task_log.to_dict(), }, 'outputs': { - 'report_1': report_status.to_dict(), - 'plot_1': plot2d_status.to_dict(), - 'plot_2': plot3d_status.to_dict(), - } + 'report_1': report_log.to_dict(), + 'plot_1': plot2d_log.to_dict(), + 'plot_2': plot3d_log.to_dict(), + }, }) - archive_status = data_model.CombineArchiveLog( + archive_log = data_model.CombineArchiveLog( status=data_model.Status.RUNNING, sed_documents={ - 'doc_1': doc_status, + 'doc_1': doc_log, }, ) - self.assertEqual(archive_status.to_dict(), { + self.assertEqual(archive_log.to_dict(), { 'status': 'RUNNING', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'sedDocuments': { - 'doc_1': doc_status.to_dict(), + 'doc_1': doc_log.to_dict(), }, }) - doc_status.combine_archive_status = archive_status - task_status.document_status = doc_status - report_status.document_status = doc_status - plot2d_status.document_status = doc_status - plot3d_status.document_status = doc_status + doc_log.parent = archive_log + task_log.parent = doc_log + report_log.parent = doc_log + plot2d_log.parent = doc_log + plot3d_log.parent = doc_log - archive_status.out_dir = self.dirname + archive_log.out_dir = os.path.join(self.dirname, 'log') - archive_status.export() - doc_status.export() - task_status.export() - report_status.export() - plot2d_status.export() - plot3d_status.export() - with open(os.path.join(self.dirname, get_config().LOG_PATH), 'r') as file: - self.assertEqual(yaml.load(file), archive_status.to_dict()) + archive_log.export() + doc_log.export() + task_log.export() + report_log.export() + plot2d_log.export() + plot3d_log.export() + with open(os.path.join(archive_log.out_dir, get_config().LOG_PATH), 'r') as file: + self.assertEqual(yaml.load(file), archive_log.to_dict()) diff --git a/tests/log/test_log_utils.py b/tests/log/test_log_utils.py index 7aff3827..decd1fda 100644 --- a/tests/log/test_log_utils.py +++ b/tests/log/test_log_utils.py @@ -91,45 +91,83 @@ def test_init_combine_archive_log(self): exp_2.outputs.append(sedml_data_model.Plot2D(id='plot_4')) SedmlSimulationWriter().run(exp_2, os.path.join(self.dirname, 'exp_2.sedml')) - status = utils.init_combine_archive_log(archive, self.dirname) + status = utils.init_combine_archive_log( + archive, self.dirname, + logged_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.Plot2D, + sedml_data_model.Plot3D, + sedml_data_model.DataSet, + sedml_data_model.Curve, + sedml_data_model.Surface, + ), + supported_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.DataSet, + ), + ) self.assertEqual(status.to_dict(), { 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'sedDocuments': { 'exp_1.sedml': { 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_1': {'status': 'QUEUED'}, - 'task_2': {'status': 'QUEUED'}, + 'task_1': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + 'task_2': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, }, 'outputs': { - 'report_1': {'status': 'QUEUED', 'dataSets': { - 'data_set_1': 'QUEUED', - 'data_set_2': 'QUEUED', - }}, - 'plot_2': {'status': 'SKIPPED', 'curves': { - 'curve_1': 'SKIPPED', - 'curve_2': 'SKIPPED', - }}, + 'report_1': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': { + 'data_set_1': 'QUEUED', + 'data_set_2': 'QUEUED', + }}, + 'plot_2': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': { + 'curve_1': 'SKIPPED', + 'curve_2': 'SKIPPED', + }}, }, }, 'exp_2.sedml': { 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_3': {'status': 'QUEUED'}, + 'task_3': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None, }, }, 'outputs': { - 'report_3': {'status': 'QUEUED', 'dataSets': {}}, - 'plot_4': {'status': 'SKIPPED', 'curves': {}}, - 'plot_5': {'status': 'SKIPPED', 'surfaces': { - 'surface_1': 'SKIPPED', - }}, + 'report_3': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': {}}, + 'plot_4': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': {}}, + 'plot_5': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'surfaces': { + 'surface_1': 'SKIPPED', + }}, }, }, }, }) - self.assertEqual(status.sed_documents['exp_1.sedml'].combine_archive_status, status) - self.assertEqual(status.sed_documents['exp_1.sedml'].tasks['task_1'].document_status, status.sed_documents['exp_1.sedml']) - self.assertEqual(status.sed_documents['exp_1.sedml'].outputs['report_1'].document_status, status.sed_documents['exp_1.sedml']) + self.assertEqual(status.sed_documents['exp_1.sedml'].parent, status) + self.assertEqual(status.sed_documents['exp_1.sedml'].tasks['task_1'].parent, status.sed_documents['exp_1.sedml']) + self.assertEqual(status.sed_documents['exp_1.sedml'].outputs['report_1'].parent, status.sed_documents['exp_1.sedml']) status = utils.init_combine_archive_log(archive, self.dirname) for doc in status.sed_documents.values(): @@ -151,35 +189,55 @@ def test_init_combine_archive_log(self): status.finalize() self.assertEqual(status.to_dict(), { 'status': 'SKIPPED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'sedDocuments': { 'exp_1.sedml': { 'status': 'SKIPPED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_1': {'status': 'SKIPPED'}, - 'task_2': {'status': 'SKIPPED'}, + 'task_1': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + 'task_2': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, }, 'outputs': { - 'report_1': {'status': 'SKIPPED', 'dataSets': { - 'data_set_1': 'SKIPPED', - 'data_set_2': 'SKIPPED', - }}, - 'plot_2': {'status': 'SKIPPED', 'curves': { - 'curve_1': 'SKIPPED', - 'curve_2': 'SKIPPED', - }}, + 'report_1': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': { + 'data_set_1': 'SKIPPED', + 'data_set_2': 'SKIPPED', + }}, + 'plot_2': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': { + 'curve_1': 'SKIPPED', + 'curve_2': 'SKIPPED', + }}, }, }, 'exp_2.sedml': { 'status': 'SKIPPED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_3': {'status': 'SKIPPED'}, + 'task_3': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, }, 'outputs': { - 'report_3': {'status': 'SKIPPED', 'dataSets': {}}, - 'plot_4': {'status': 'SKIPPED', 'curves': {}}, - 'plot_5': {'status': 'SKIPPED', 'surfaces': { - 'surface_1': 'SKIPPED', - }}, + 'report_3': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': {}}, + 'plot_4': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': {}}, + 'plot_5': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'surfaces': { + 'surface_1': 'SKIPPED', + }}, }, }, }, @@ -206,36 +264,309 @@ def test_init_combine_archive_log(self): status.finalize() self.assertEqual(status.to_dict(), { 'status': 'FAILED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'sedDocuments': { 'exp_1.sedml': { 'status': 'FAILED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_1': {'status': 'FAILED'}, - 'task_2': {'status': 'FAILED'}, + 'task_1': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + 'task_2': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, }, 'outputs': { - 'report_1': {'status': 'FAILED', 'dataSets': { - 'data_set_1': 'FAILED', - 'data_set_2': 'FAILED', - }}, - 'plot_2': {'status': 'FAILED', 'curves': { - 'curve_1': 'FAILED', - 'curve_2': 'FAILED', - }}, + 'report_1': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': { + 'data_set_1': 'FAILED', + 'data_set_2': 'FAILED', + }}, + 'plot_2': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': { + 'curve_1': 'FAILED', + 'curve_2': 'FAILED', + }}, }, }, 'exp_2.sedml': { 'status': 'FAILED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': { + 'task_3': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + }, + 'outputs': { + 'report_3': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': {}}, + 'plot_4': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': {}}, + 'plot_5': {'status': 'FAILED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'surfaces': { + 'surface_1': 'FAILED', + }}, + }, + }, + }, + }) + + # test logging subsets of possible features -- no data sets, curves, surfaces + status = utils.init_combine_archive_log( + archive, self.dirname, + logged_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.Plot2D, + sedml_data_model.Plot3D, + ), + supported_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.DataSet, + ), + ) + self.assertEqual(status.to_dict(), { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'sedDocuments': { + 'exp_1.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': { + 'task_1': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + 'task_2': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + }, + 'outputs': { + 'report_1': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': None}, + 'plot_2': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': None}, + }, + }, + 'exp_2.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': { + 'task_3': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None, }, + }, + 'outputs': { + 'report_3': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': None}, + 'plot_4': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'curves': None}, + 'plot_5': {'status': 'SKIPPED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'surfaces': None}, + }, + }, + }, + }) + + # test logging subsets of possible features -- no plots + status = utils.init_combine_archive_log( + archive, self.dirname, + logged_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + ), + supported_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.DataSet, + ), + ) + self.assertEqual(status.to_dict(), { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'sedDocuments': { + 'exp_1.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': { + 'task_1': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + 'task_2': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + }, + 'outputs': { + 'report_1': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': None}, + 'plot_2': None, + }, + }, + 'exp_2.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_3': {'status': 'FAILED'}, + 'task_3': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None, }, }, 'outputs': { - 'report_3': {'status': 'FAILED', 'dataSets': {}}, - 'plot_4': {'status': 'FAILED', 'curves': {}}, - 'plot_5': {'status': 'FAILED', 'surfaces': { - 'surface_1': 'FAILED', - }}, + 'report_3': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'dataSets': None}, + 'plot_4': None, + 'plot_5': None, + }, + }, + }, + }) + + # test logging subsets of possible features -- no outputs + status = utils.init_combine_archive_log( + archive, self.dirname, + logged_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + ), + supported_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.DataSet, + ), + ) + self.assertEqual(status.to_dict(), { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'sedDocuments': { + 'exp_1.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': { + 'task_1': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + 'task_2': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None}, + }, + 'outputs': None, + }, + 'exp_2.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': { + 'task_3': {'status': 'QUEUED', 'exception': None, 'skipReason': None, 'output': None, 'duration': None, + 'algorithm': None, 'simulatorDetails': None, }, }, + 'outputs': None, }, }, }) + + # test logging subsets of possible features -- no tasks or outputs + status = utils.init_combine_archive_log( + archive, self.dirname, + logged_features=( + sedml_data_model.SedDocument, + ), + supported_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.DataSet, + ), + ) + self.assertEqual(status.to_dict(), { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'sedDocuments': { + 'exp_1.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': None, + 'outputs': None, + }, + 'exp_2.sedml': { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'tasks': None, + 'outputs': None, + }, + }, + }) + + # test logging subsets of possible features -- no SED documents + status = utils.init_combine_archive_log( + archive, self.dirname, + logged_features=( + ), + supported_features=( + sedml_data_model.SedDocument, + sedml_data_model.Task, + sedml_data_model.Report, + sedml_data_model.DataSet, + ), + ) + self.assertEqual(status.to_dict(), { + 'status': 'QUEUED', + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, + 'sedDocuments': None, + }) + + def test_get_summary_combine_archive_log_tasks_outputs_unknown_status(self): + log = data_model.CombineArchiveLog( + sed_documents={ + 'doc_1': data_model.SedDocumentLog( + tasks={ + 'task_1': None, + }, + outputs={ + 'output_1': None, + }, + ), + } + ) + summary = utils.get_summary_combine_archive_log(log) + self.assertIn('Unknown: 1', summary) diff --git a/tests/sedml/test_sedml_exec.py b/tests/sedml/test_sedml_exec.py index 586804a6..435a4c96 100644 --- a/tests/sedml/test_sedml_exec.py +++ b/tests/sedml/test_sedml_exec.py @@ -6,13 +6,16 @@ from biosimulators_utils.sedml import data_model from biosimulators_utils.sedml import exec from biosimulators_utils.sedml import io -from biosimulators_utils.sedml.warnings import NoTasksWarning, NoOutputsWarning, RepeatDataSetLabelsWarning, SedmlFeatureNotSupportedWarning +from biosimulators_utils.sedml.exceptions import SedmlExecutionError +from biosimulators_utils.sedml.warnings import (NoTasksWarning, NoOutputsWarning, RepeatDataSetLabelsWarning, + SedmlFeatureNotSupportedWarning, InconsistentVariableShapesWarning) from lxml import etree from unittest import mock import numpy import numpy.testing import os import pandas +import requests import shutil import tempfile import unittest @@ -35,7 +38,7 @@ def test_successful(self): )) doc.models.append(data_model.Model( id='model2', - source='model1.xml', + source='https://models.edu/model1.xml', language='urn:sedml:language:cellml', )) @@ -164,13 +167,18 @@ def test_successful(self): label='dataset_6', data_generator=doc.data_generators[3], ), + data_model.DataSet( + id='dataset_7', + label='dataset_7', + data_generator=doc.data_generators[3], + ), ], )) filename = os.path.join(self.tmp_dir, 'test.sedml') io.SedmlSimulationWriter().run(doc, filename) - def execute_task(task, variables): + def execute_task(task, variables, log): results = DataGeneratorVariableResults() if task.id == 'task_1_ss': results[doc.data_generators[0].variables[0].id] = numpy.array((1., 2.)) @@ -178,15 +186,16 @@ def execute_task(task, variables): else: results[doc.data_generators[2].variables[0].id] = numpy.array((5., 6.)) results[doc.data_generators[3].variables[0].id] = numpy.array((7., 8.)) - return results + return results, log working_dir = os.path.dirname(filename) with open(os.path.join(working_dir, doc.models[0].source), 'w'): pass out_dir = os.path.join(self.tmp_dir, 'results') - output_results = exec.exec_sed_doc(execute_task, filename, working_dir, - out_dir, report_formats=[ReportFormat.csv], plot_formats=[]) + with mock.patch('requests.get', return_value=mock.Mock(raise_for_status=lambda: None, content=b'')): + output_results, _ = exec.exec_sed_doc(execute_task, filename, working_dir, + out_dir, report_formats=[ReportFormat.csv], plot_formats=[]) expected_output_results = OutputResults({ doc.outputs[0].id: pandas.DataFrame( @@ -212,8 +221,9 @@ def execute_task(task, variables): doc.outputs[3].id: pandas.DataFrame( numpy.array([ numpy.array((7., 8.)), + numpy.array((7., 8.)), ]), - index=['dataset_6'], + index=['dataset_6', 'dataset_7'], ), }) self.assertEqual(sorted(output_results.keys()), sorted(expected_output_results.keys())) @@ -227,6 +237,8 @@ def execute_task(task, variables): self.assertTrue(output_results[doc.outputs[1].id].equals(df)) # save in HDF5 format + doc.models[1].source = doc.models[0].source + io.SedmlSimulationWriter().run(doc, filename) shutil.rmtree(out_dir) exec.exec_sed_doc(execute_task, filename, os.path.dirname(filename), out_dir, report_formats=[ReportFormat.h5], plot_formats=[]) @@ -239,7 +251,6 @@ def execute_task(task, variables): # track execution status shutil.rmtree(out_dir) log = SedDocumentLog( - status=Status.QUEUED, tasks={ 'task_1_ss': TaskLog(status=Status.QUEUED), 'task_2_time_course': TaskLog(status=Status.QUEUED), @@ -258,26 +269,53 @@ def execute_task(task, variables): }), 'report_4': ReportLog(status=Status.QUEUED, data_sets={ 'dataset_6': Status.QUEUED, + 'dataset_7': Status.QUEUED, }) }, ) - log.combine_archive_status = CombineArchiveLog(out_dir=out_dir) - log.tasks['task_1_ss'].document_status = log - log.tasks['task_2_time_course'].document_status = log - log.outputs['report_1'].document_status = log - log.outputs['report_2'].document_status = log - log.outputs['report_3'].document_status = log + log.parent = CombineArchiveLog(out_dir=out_dir) + log.tasks['task_1_ss'].parent = log + log.tasks['task_2_time_course'].parent = log + log.outputs['report_1'].parent = log + log.outputs['report_2'].parent = log + log.outputs['report_3'].parent = log + log.outputs['report_4'].parent = log exec.exec_sed_doc(execute_task, filename, os.path.dirname(filename), out_dir, report_formats=[ReportFormat.h5], plot_formats=[], log=log) - self.assertEqual(log.to_dict(), { - 'status': 'SUCCEEDED', + + expected_log = { + 'status': None, + 'exception': None, + 'skipReason': None, + 'output': None, + 'duration': None, 'tasks': { - 'task_1_ss': {'status': 'SUCCEEDED'}, - 'task_2_time_course': {'status': 'SUCCEEDED'}, + 'task_1_ss': { + 'status': 'SUCCEEDED', + 'exception': None, + 'skipReason': None, + 'output': log.tasks['task_1_ss'].output, + 'duration': log.tasks['task_1_ss'].duration, + 'algorithm': None, + 'simulatorDetails': None, + }, + 'task_2_time_course': { + 'status': 'SUCCEEDED', + 'exception': None, + 'skipReason': None, + 'output': log.tasks['task_2_time_course'].output, + 'duration': log.tasks['task_2_time_course'].duration, + 'algorithm': None, + 'simulatorDetails': None, + }, }, 'outputs': { 'report_1': { 'status': 'SUCCEEDED', + 'exception': None, + 'skipReason': None, + 'output': log.outputs['report_1'].output, + 'duration': log.outputs['report_1'].duration, 'dataSets': { 'dataset_1': 'SUCCEEDED', 'dataset_2': 'SUCCEEDED', @@ -285,6 +323,10 @@ def execute_task(task, variables): }, 'report_2': { 'status': 'SUCCEEDED', + 'exception': None, + 'skipReason': None, + 'output': log.outputs['report_2'].output, + 'duration': log.outputs['report_2'].duration, 'dataSets': { 'dataset_3': 'SUCCEEDED', 'dataset_4': 'SUCCEEDED', @@ -292,18 +334,28 @@ def execute_task(task, variables): }, 'report_3': { 'status': 'SUCCEEDED', + 'exception': None, + 'skipReason': None, + 'output': log.outputs['report_3'].output, + 'duration': log.outputs['report_3'].duration, 'dataSets': { 'dataset_5': 'SUCCEEDED', }, }, 'report_4': { 'status': 'SUCCEEDED', + 'exception': None, + 'skipReason': None, + 'output': log.outputs['report_4'].output, + 'duration': log.outputs['report_4'].duration, 'dataSets': { 'dataset_6': 'SUCCEEDED', + 'dataset_7': 'SUCCEEDED', }, }, }, - }) + } + self.assertEqual(log.to_dict(), expected_log) self.assertTrue(os.path.isfile(os.path.join(out_dir, get_config().LOG_PATH))) def test_with_model_changes(self): @@ -363,20 +415,20 @@ def test_with_model_changes(self): os.path.join(os.path.dirname(__file__), '..', 'fixtures', 'sbml-three-species.xml'), os.path.join(working_dir, 'model1.xml')) - def execute_task(task, variables): + def execute_task(task, variables, log): et = etree.parse(task.model.source) obj_xpath, _, attr = variables[0].target.rpartition('/@') obj = et.xpath(obj_xpath, namespaces={'sbml': 'http://www.sbml.org/sbml/level3/version2'})[0] results = DataGeneratorVariableResults() results[doc.data_generators[0].variables[0].id] = numpy.array((float(obj.get(attr)),)) - return results + return results, log out_dir = os.path.join(self.tmp_dir, 'results') - report_results = exec.exec_sed_doc(execute_task, filename, working_dir, out_dir, apply_xml_model_changes=False) + report_results, _ = exec.exec_sed_doc(execute_task, filename, working_dir, out_dir, apply_xml_model_changes=False) numpy.testing.assert_equal(report_results[doc.outputs[0].id].loc[doc.outputs[0].data_sets[0].id, :], numpy.array((1., ))) - report_results = exec.exec_sed_doc(execute_task, filename, working_dir, out_dir, apply_xml_model_changes=True) + report_results, _ = exec.exec_sed_doc(execute_task, filename, working_dir, out_dir, apply_xml_model_changes=True) numpy.testing.assert_equal(report_results[doc.outputs[0].id].loc[doc.outputs[0].data_sets[0].id, :], numpy.array((2., ))) def test_warnings(self): @@ -386,8 +438,8 @@ def test_warnings(self): filename = os.path.join(self.tmp_dir, 'test.sedml') io.SedmlSimulationWriter().run(doc, filename) - def execute_task(task, variables): - return DataGeneratorVariableResults() + def execute_task(task, variables, log): + return DataGeneratorVariableResults(), log out_dir = os.path.join(self.tmp_dir, 'results') with self.assertWarns(NoTasksWarning): @@ -466,11 +518,11 @@ def execute_task(task, variables): filename = os.path.join(self.tmp_dir, 'test.sedml') io.SedmlSimulationWriter().run(doc, filename) - def execute_task(task, variables): + def execute_task(task, variables, log): if task.id == 'task1': - return DataGeneratorVariableResults({'data_gen_1_var_1': numpy.array(1.)}) + return DataGeneratorVariableResults({'data_gen_1_var_1': numpy.array(1.)}), log else: - return DataGeneratorVariableResults() + return DataGeneratorVariableResults(), log working_dir = os.path.dirname(filename) with open(os.path.join(working_dir, doc.models[0].source), 'w'): @@ -527,15 +579,15 @@ def test_errors(self): filename = os.path.join(self.tmp_dir, 'test.sedml') io.SedmlSimulationWriter().run(doc, filename) - def execute_task(task, variables): - return DataGeneratorVariableResults() + def execute_task(task, variables, log): + return DataGeneratorVariableResults(), log working_dir = os.path.dirname(filename) with open(os.path.join(working_dir, doc.models[0].source), 'w'): pass out_dir = os.path.join(self.tmp_dir, 'results') - with self.assertRaisesRegex(ValueError, 'must be generated for task'): + with self.assertRaisesRegex(SedmlExecutionError, 'did not generate the following expected variables'): exec.exec_sed_doc(execute_task, filename, working_dir, out_dir) # error: unsupported type of task @@ -544,7 +596,7 @@ def execute_task(task, variables): id='task_1_ss', )) out_dir = os.path.join(self.tmp_dir, 'results') - with self.assertRaisesRegex(NotImplementedError, 'not supported'): + with self.assertRaisesRegex(SedmlExecutionError, 'not supported'): exec.exec_sed_doc(execute_task, doc, '.', out_dir) # error: unsupported data generators @@ -596,11 +648,11 @@ def execute_task(task, variables): ], )) - def execute_task(task, variables): + def execute_task(task, variables, log): results = DataGeneratorVariableResults() results[doc.data_generators[0].variables[0].id] = numpy.array((1.,)) results[doc.data_generators[0].variables[1].id] = numpy.array((1.,)) - return results + return results, log working_dir = self.tmp_dir with open(os.path.join(working_dir, doc.models[0].source), 'w'): @@ -637,17 +689,17 @@ def execute_task(task, variables): ) ] - def execute_task(task, variables): + def execute_task(task, variables, log): results = DataGeneratorVariableResults() results[doc.data_generators[0].variables[0].id] = numpy.array((1.,)) - return results + return results, log working_dir = self.tmp_dir with open(os.path.join(working_dir, doc.models[0].source), 'w'): pass out_dir = os.path.join(self.tmp_dir, 'results') - with self.assertRaisesRegex(ValueError, 'could not be evaluated'): + with self.assertRaisesRegex(SedmlExecutionError, 'could not be evaluated'): exec.exec_sed_doc(execute_task, doc, working_dir, out_dir) # error: variables have inconsistent shapes @@ -685,18 +737,18 @@ def execute_task(task, variables): ), ] - def execute_task(task, variables): + def execute_task(task, variables, log): results = DataGeneratorVariableResults() results[doc.data_generators[0].variables[0].id] = numpy.array((1.,)) results[doc.data_generators[0].variables[1].id] = numpy.array((1., 2.)) - return results + return results, log working_dir = self.tmp_dir with open(os.path.join(working_dir, doc.models[0].source), 'w'): pass out_dir = os.path.join(self.tmp_dir, 'results') - with self.assertRaisesRegex(ValueError, 'must have consistent shape'): + with self.assertWarnsRegex(InconsistentVariableShapesWarning, 'do not have consistent shapes'): exec.exec_sed_doc(execute_task, doc, working_dir, out_dir) # error: data generators have inconsistent shapes @@ -757,12 +809,12 @@ def execute_task(task, variables): ), ] - def execute_task(task, variables): + def execute_task(task, variables, log): results = DataGeneratorVariableResults() results[doc.data_generators[0].variables[0].id] = numpy.array((1.,)) results[doc.data_generators[1].variables[0].id] = numpy.array((1., 2.)) results[doc.data_generators[2].variables[0].id] = numpy.array(((1., 2., 3.), (4., 5., 6.), (7., 8., 9.))) - return results + return results, log working_dir = self.tmp_dir with open(os.path.join(working_dir, doc.models[0].source), 'w'): @@ -770,7 +822,7 @@ def execute_task(task, variables): out_dir = os.path.join(self.tmp_dir, 'results') with self.assertWarnsRegex(UserWarning, 'do not have consistent shapes'): - report_results = exec.exec_sed_doc(execute_task, doc, working_dir, out_dir) + report_results, _ = exec.exec_sed_doc(execute_task, doc, working_dir, out_dir) numpy.testing.assert_equal(report_results[doc.outputs[0].id].loc[doc.outputs[0].data_sets[0].id, :], numpy.array((1., numpy.nan))) numpy.testing.assert_equal(report_results[doc.outputs[0].id].loc[doc.outputs[0].data_sets[1].id, :], numpy.array((1., 2.))) @@ -788,7 +840,7 @@ def execute_task(task, variables): #out_dir = os.path.join(self.tmp_dir, 'results2') # with self.assertWarnsRegex(UserWarning, 'do not have consistent shapes'): - # report_results = exec.exec_sed_doc(execute_task, doc, working_dir, out_dir) + # report_results, _ = exec.exec_sed_doc(execute_task, doc, working_dir, out_dir) # numpy.testing.assert_equal(report_results[doc.outputs[0].id].loc[doc.outputs[0].data_sets[0].id, :], # numpy.array(((1., numpy.nan, numpy.nan), (numpy.nan, numpy.nan, numpy.nan), (numpy.nan, numpy.nan, numpy.nan)))) # numpy.testing.assert_equal(report_results[doc.outputs[0].id].loc[doc.outputs[0].data_sets[1].id, :], @@ -842,11 +894,11 @@ def execute_task(task, variables): ), ] - def execute_task(task, variables): + def execute_task(task, variables, log): results = DataGeneratorVariableResults() results[doc.data_generators[0].variables[0].id] = numpy.array((1., 2.)) results[doc.data_generators[1].variables[0].id] = numpy.array((2., 3.)) - return results + return results, log working_dir = self.tmp_dir with open(os.path.join(working_dir, doc.models[0].source), 'w'): @@ -876,13 +928,17 @@ def execute_task(task, variables): # error: unsupported outputs doc.outputs = [ - None + mock.Mock(id='unsupported') ] working_dir = self.tmp_dir with open(os.path.join(working_dir, doc.models[0].source), 'w'): pass - out_dir = os.path.join(self.tmp_dir, 'results') - with self.assertRaisesRegex(NotImplementedError, 'are not supported'): - exec.exec_sed_doc(execute_task, doc, working_dir, out_dir) + log = SedDocumentLog(tasks={}, outputs={}) + for task in doc.tasks: + log.tasks[task.id] = TaskLog(parent=log) + for output in doc.outputs: + log.outputs[output.id] = ReportLog(parent=log) + with self.assertRaisesRegex(SedmlExecutionError, 'are not supported'): + exec.exec_sed_doc(execute_task, doc, working_dir, out_dir, log=log) diff --git a/tests/sedml/test_sedml_utils.py b/tests/sedml/test_sedml_utils.py index 4d439d2d..e471e126 100644 --- a/tests/sedml/test_sedml_utils.py +++ b/tests/sedml/test_sedml_utils.py @@ -370,6 +370,72 @@ def test_calc_data_generator_results(self): with self.assertRaises(ValueError): utils.calc_data_generator_results(data_gen, var_results) + def test_calc_data_generator_results_diff_shapes(self): + data_gen = data_model.DataGenerator( + id='data_gen_1', + variables=[ + data_model.DataGeneratorVariable(id='var_1'), + data_model.DataGeneratorVariable(id='var_2'), + ], + math='var_1 + var_2', + ) + + var_results = { + 'var_1': numpy.array(2.), + 'var_2': numpy.array(3.), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array(5.)) + + var_results = { + 'var_1': numpy.array([2.]), + 'var_2': numpy.array([3.]), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array([5.])) + + var_results = { + 'var_1': numpy.array([[2.]]), + 'var_2': numpy.array([[3.]]), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array([[5.]])) + + var_results = { + 'var_1': numpy.array(2.), + 'var_2': numpy.array([3, 5.]), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array([5., numpy.nan])) + + var_results = { + 'var_1': numpy.array([2.]), + 'var_2': numpy.array([3, 5.]), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array([5., numpy.nan])) + + var_results = { + 'var_1': numpy.array([[2.]]), + 'var_2': numpy.array([3, 5.]), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array([[5.], [numpy.nan]])) + + var_results = { + 'var_1': numpy.array(2.), + 'var_2': numpy.array([[3, 5., 1.], [4., 7., 1.]]), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array([[5., numpy.nan, numpy.nan], [numpy.nan, numpy.nan, numpy.nan]])) + + var_results = { + 'var_2': numpy.array(2.), + 'var_1': numpy.array([[3, 5., 1.], [4., 7., 1.]]), + } + numpy.testing.assert_allclose(utils.calc_data_generator_results(data_gen, var_results), + numpy.array([[5., numpy.nan, numpy.nan], [numpy.nan, numpy.nan, numpy.nan]])) + def test_remove_model_changes(self): doc = data_model.SedDocument( models=[ diff --git a/tests/simulator/test_simulator_warnings.py b/tests/simulator/test_simulator_warnings.py index e0ed3a65..a12ac4af 100644 --- a/tests/simulator/test_simulator_warnings.py +++ b/tests/simulator/test_simulator_warnings.py @@ -1,8 +1,8 @@ -from biosimulators_utils.simulator import warnings as simulator_warnings +from biosimulators_utils.simulator.warnings import AlgorithmSubstitutedWarning +from biosimulators_utils.warnings import warn import unittest -import warnings class SimulatorWarningsTestCase(unittest.TestCase): def test(self): - warnings.warn('Alternate algorithm used', simulator_warnings.AlgorithmSubstitutedWarning) + warn('Alternate algorithm used', AlgorithmSubstitutedWarning)