diff --git a/README.md b/README.md index c8c460dbf..146594a70 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ MIRA is a framework for representing systems using ontology-grounded **meta-mode * Using the MIRA Domain Knowledge Graph REST API: [Notebook 5](https://github.com/indralab/mira/blob/main/notebooks/dkg_api.ipynb) * Using the Model REST API to perform various model operations: [Notebook 6](https://github.com/indralab/mira/blob/main/notebooks/model_api.ipynb) * Using the web client in python that connects to the REST API: [Notebook 7](https://github.com/indralab/mira/blob/main/notebooks/web_client.ipynb) +* Demonstrating MIRA TemplateModel capabilities [Notebook 8](https://github.com/gyorilab/mira/blob/main/notebooks/Hackathon%20Scenario%201.ipynb) +* Rapid construction of DKGs in ASKEM: [Notebook 9](https://github.com/gyorilab/mira/blob/main/notebooks/Rapid%20construction%20of%20new%20DKGs.ipynb) +* Implement a masking intervention in a compartmental model to simulate + epidemic trajectories under different scenarios: + [Notebook 10](https://github.com/gyorilab/mira/blob/main/notebooks/hackathon_2023.07/scenario1.ipynb) +* Benchmarking the efficacy of DKG groundings on a set of COVID EPI Models: + [Notebook 11](https://github.com/gyorilab/mira/blob/main/notebooks/hackathon_2023.10/Model%20Comparison.ipynb) + [//]: # (Gromet Export fixme: uncomment when gromet works again) diff --git a/docs/source/examples.rst b/docs/source/examples.rst new file mode 100644 index 000000000..ba40cd428 --- /dev/null +++ b/docs/source/examples.rst @@ -0,0 +1,22 @@ +Examples +======== + +This module contains examples of how to assemble and modify models in MIRA. + +Curated Example Models +---------------------- + +* `sir.py `_ - Simple examples of SIR epi models +* `chime.py `_ - Simple example of a SVIIvR epi model +* `mech_bayes.py `_ A curated model describing the Mech Bayes model (an SEIRD epi model). +* `nabi2021.py `_ A curated model describing the Nabi et al. 2021 model (an 'SEIQAIRDL' epi model). See https://doi.org/10.1016/j.chaos.2021.110689 for more information. +* `jin2022.py `_ A curated model describing the Jin et al. 2022 model, describing a vaccine-stratified epi model. + + +Decapode Examples (:py:mod:`mira.examples.decapodes.decapodes_examples`) +------------------------------------------------------------------------ +.. automodule:: mira.examples.decapodes.decapodes_examples + :members: + :show-inheritance: + +.. mdinclude:: ../../mira/examples/decapodes/decapodes_vs_decaexpr_composite/README.md diff --git a/docs/source/index.rst b/docs/source/index.rst index 3a25d9384..3da818c3d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ Table of Contents dkg metaregistry terarium_client + examples Indices and Tables ------------------ diff --git a/docs/source/metamodel.rst b/docs/source/metamodel.rst index 1f6db6386..623464827 100644 --- a/docs/source/metamodel.rst +++ b/docs/source/metamodel.rst @@ -14,7 +14,7 @@ Templates (:py:mod:`mira.metamodel.templates`) ---------------------------------------------- .. automodule:: mira.metamodel.templates :members: - :exclude-members: Concept, ControlledConversion, NaturalConversion, Provenance, Template, NaturalDegradation, NaturalProduction, GroupedControlledConversion + :exclude-members: Concept :show-inheritance: Operations (:py:mod:`mira.metamodel.ops`) @@ -58,3 +58,9 @@ Utilities (:py:mod:`mira.metamodel.utils`) .. automodule:: mira.metamodel.utils :members: :show-inheritance: + +Decapodes (:py:mod:`mira.metamodel.decapodes`) +---------------------------------------------- +.. automodule:: mira.metamodel.decapodes + :members: + :show-inheritance: diff --git a/docs/source/modeling.rst b/docs/source/modeling.rst index a2aea619a..3d61b9545 100644 --- a/docs/source/modeling.rst +++ b/docs/source/modeling.rst @@ -1,29 +1,32 @@ Modeling ======== + +Modeling module (:py:mod:`mira.modeling`) +----------------------------------------- .. automodule:: mira.modeling :members: :show-inheritance: ASKEM AMR Petri net generation (:py:mod:`mira.modeling.amr.petrinet`) -------------------------------------------------------------------------- +--------------------------------------------------------------------- .. automodule:: mira.modeling.amr.petrinet :members: :show-inheritance: ASKEM AMR Stockflow generation (:py:mod:`mira.modeling.amr.stockflow`) -------------------------------------------------------------------------- +---------------------------------------------------------------------- .. automodule:: mira.modeling.amr.stockflow :members: :show-inheritance: ASKEM AMR operations (:py:mod:`mira.modeling.amr.ops`) ----------------------------------------------------------- +------------------------------------------------------ .. automodule:: mira.modeling.amr.ops :members: :show-inheritance: ASKEM AMR Regulatory net generation (:py:mod:`mira.modeling.amr.regnet`) ----------------------------------------------------------------------------- +------------------------------------------------------------------------ .. automodule:: mira.modeling.amr.regnet :members: :show-inheritance: @@ -41,13 +44,13 @@ ODE model generation and simulation (:py:mod:`mira.modeling.ode`) :show-inheritance: ACSets Petri net model generation (:py:mod:`mira.modeling.acsets.petri`) ------------------------------------------------------------------ +------------------------------------------------------------------------ .. automodule:: mira.modeling.acsets.petri :members: :show-inheritance: ACSets Stockflow model generation (:py:mod:`mira.modeling.acsets.stockflow`) ------------------------------------------------------------------ +---------------------------------------------------------------------------- .. automodule:: mira.modeling.acsets.stockflow :members: :show-inheritance: diff --git a/docs/source/sources.rst b/docs/source/sources.rst index 5983f5c1c..d57c5f716 100644 --- a/docs/source/sources.rst +++ b/docs/source/sources.rst @@ -5,32 +5,32 @@ Sources of model content :show-inheritance: ASKEM AMR (:py:mod:`mira.sources.amr`) ------------------------------------------- +-------------------------------------- .. automodule:: mira.sources.amr :members: :show-inheritance: ASKEM AMR Petri nets (:py:mod:`mira.sources.amr.petrinet`) --------------------------------------------------------------- +---------------------------------------------------------- .. automodule:: mira.sources.amr.petrinet :members: :show-inheritance: ASKEM AMR Stockflow (:py:mod:`mira.sources.amr.stockflow`) --------------------------------------------------------------- +---------------------------------------------------------- .. automodule:: mira.sources.amr.stockflow :members: :show-inheritance: ASKEM AMR Regulatory nets (:py:mod:`mira.sources.amr.regnet`) ------------------------------------------------------------------ +------------------------------------------------------------- .. automodule:: mira.sources.amr.regnet :members: - :show-inherita + :show-inheritance: Reconstruct ODE semantics (:py:mod:`mira.sources.amr.flux_span`) --------------------------------------------------------------------- +---------------------------------------------------------------- .. automodule:: mira.sources.amr.flux_span :members: :show-inheritance: @@ -45,6 +45,7 @@ BioModels client (:py:mod:`mira.sources.biomodels`) --------------------------------------------------- .. automodule:: mira.sources.biomodels :members: + :exclude-members: main :show-inheritance: Bilayer extraction (:py:mod:`mira.sources.bilayer`) @@ -54,13 +55,31 @@ Bilayer extraction (:py:mod:`mira.sources.bilayer`) :show-inheritance: ACSets Petri Net extraction (:py:mod:`mira.sources.acsets.petri`) ----------------------------------------------------------- +----------------------------------------------------------------- .. automodule:: mira.sources.acsets.petri :members: :show-inheritance: ACSets Stockflow extraction (:py:mod:`mira.sources.acsets.stockflow`) ----------------------------------------------------------- +--------------------------------------------------------------------- .. automodule:: mira.sources.acsets.stockflow :members: :show-inheritance: + +ACSets Decapodes extraction (:py:mod:`mira.sources.acsets.decapodes.decapodes`) +------------------------------------------------------------------------------- +.. automodule:: mira.sources.acsets.decapodes.decapodes + :members: + :show-inheritance: + +ACSets DecaExpr extraction (:py:mod:`mira.sources.acsets.decapodes.deca_expr`) +------------------------------------------------------------------------------ +.. automodule:: mira.sources.acsets.decapodes.deca_expr + :members: + :show-inheritance: + +Utility Methods (:py:mod:`mira.sources.util`) +--------------------------------------------- +.. automodule:: mira.sources.util + :members: + :show-inheritance: diff --git a/mira/dkg/api.py b/mira/dkg/api.py index 73bfb3e5d..1278df02c 100644 --- a/mira/dkg/api.py +++ b/mira/dkg/api.py @@ -158,7 +158,6 @@ def get_transitive_closure( relation_types: List[str] = Query( ..., description="A list of relation types to get a transitive closure for", - title="This is a title", example=DKG_REFINER_RELS, ), ): @@ -332,11 +331,17 @@ def get_relations( class IsOntChildResult(BaseModel): """Result of a query to /is_ontological_child""" - child_curie: str = Field(..., description="The child CURIE") - parent_curie: str = Field(..., description="The parent CURIE") - is_child: bool = Field(..., description="True if the child CURIE is an " - "ontological child of the parent " - "CURIE") + child_curie: str = Field(..., + example="vo:0001113", + description="The child CURIE") + parent_curie: str = Field(..., + example="obi:0000047", + description="The parent CURIE") + is_child: bool = Field( + ..., + description="True if the child CURIE is an ontological child of the " + "parent CURIE" + ) class IsOntChildQuery(BaseModel): @@ -379,7 +384,7 @@ def is_ontological_child( ) def search( request: Request, - q: str = Query(..., example="infect"), + q: str = Query(..., example="infect", description="The search query"), limit: int = 25, offset: int = 0, prefixes: Optional[str] = Query( @@ -463,6 +468,7 @@ def entity_similarity( request: Request, sources: List[str] = Body( ..., + description="A list of CURIEs to use as sources", title="source CURIEs", examples=[["ido:0000511", "ido:0000592", "ido:0000597", "ido:0000514"]], ), diff --git a/mira/dkg/model.py b/mira/dkg/model.py index c1dc6d15a..a67e4ba2e 100644 --- a/mira/dkg/model.py +++ b/mira/dkg/model.py @@ -21,7 +21,7 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, Field -from mira.examples.sir import sir_bilayer, sir, sir_parameterized_init +from mira.examples.sir import sir_bilayer, sir, sir_parameterized_init, sir_2_city from mira.metamodel import ( NaturalConversion, Template, ControlledConversion, stratify, Concept, ModelComparisonGraphdata, TemplateModelDelta, @@ -90,6 +90,7 @@ #: PetriNetModel json example petrinet_json = PetriNetModel(Model(sir)).to_pydantic() amr_petrinet_json = AMRPetriNetModel(Model(sir)).to_pydantic() +amr_petrinet_json_2_city = AMRPetriNetModel(Model(sir_2_city)).to_pydantic() amr_petrinet_json_units_values = AMRPetriNetModel( Model(sir_parameterized_init) ).to_pydantic() @@ -101,9 +102,9 @@ tags=["modeling"], description=dedent("""\ This endpoint consumes a JSON representation of a MIRA template model and converts - it into the ACSet standard for petri nets (implicitly defined `here _), which can be used with the - Algebraic Julia ecosystem. + it into the ACSet standard for petri nets (implicitly defined + [here](https://github.com/AlgebraicJulia/py-acsets/blob/main/src/acsets/petris.py)), + which can be used with the Algebraic Julia ecosystem. Note, this endpoint used to be called "/api/to_petrinet" but has been renamed as the ASKEM standard now uses that endpoint. @@ -123,9 +124,10 @@ def model_to_petri(template_model: Dict[str, Any] = Body(..., example=template_m tags=["modeling"], response_model=TemplateModel, description=dedent("""\ - This endpoint consumes a JSON representation of an `ACSet petri net _ and produces a JSON representation - of a MIRA template model, which can be directly used with the MIRA ecosystem to do model + This endpoint consumes a JSON representation of an + [ACSet petri net](https://github.com/AlgebraicJulia/py-acsets/blob/main/src/acsets/petris.py) + and produces a JSON representation of a MIRA template model, + which can be directly used with the MIRA ecosystem to do model extension, stratification, and comparison. Note, this endpoint used to be called "/api/from_petrinet" but has been renamed as the ASKEM @@ -143,9 +145,10 @@ def petri_to_model(petri_json: Dict[str, Any] = Body(..., example=petrinet_json) tags=["modeling"], description=dedent("""\ This endpoint consumes a JSON representation of a MIRA template model and converts - it into the `ASKEM standard for petri nets _, which can be directly - consumed by other project members that implement this standard. + it into the + [ASKEM standard for petri nets](https://github.com/DARPA-ASKEM/Model-Representations/blob/main/petrinet/petrinet_schema.json), + which can be directly consumed by other project members that + implement this standard. """.rstrip()), ) def model_to_amr(template_model: Dict[str, Any] = Body(..., example=template_model_example)): @@ -161,10 +164,11 @@ def model_to_amr(template_model: Dict[str, Any] = Body(..., example=template_mod tags=["modeling"], response_model=TemplateModel, description=dedent("""\ - This endpoint consumes a JSON representation of an `ASKEM petri net _ and - produces a JSON representation of a MIRA template model, which can be directly used - with the MIRA ecosystem to do model extension, stratification, and comparison. + This endpoint consumes a JSON representation of an + [ASKEM petri net](https://github.com/DARPA-ASKEM/Model-Representations/blob/main/petrinet/petrinet_schema.json) + and produces a JSON representation of a MIRA template model, + which can be directly used with the MIRA ecosystem to do model + extension, stratification, and comparison. """.rstrip()), ) def amr_to_model(amr_json: Dict[str, Any] = Body(..., example=amr_petrinet_json)): @@ -638,9 +642,10 @@ def add_transition( ..., example={ "template_model": template_model_example, - "subject_concept": "", - "object_concept": "", - "parameter": "", + "subject_concept": {"name": "infected population", + "identifiers": {"ido": "0000511"}}, + "outcome_concept": {"name": "dead", + "identifiers": {"ncit": "C28554"}}, }, ) ): @@ -695,9 +700,7 @@ def model_comparison( class AMRComparisonQuery(BaseModel): petrinet_models: List[Dict[str, Any]] = Field( - ..., example=[ # fixme: create more examples - amr_petrinet_json, - ] + ..., example=[amr_petrinet_json, amr_petrinet_json_2_city] ) diff --git a/mira/dkg/wsgi.py b/mira/dkg/wsgi.py index 179cea73c..8fcccc03a 100644 --- a/mira/dkg/wsgi.py +++ b/mira/dkg/wsgi.py @@ -69,7 +69,7 @@ ), contact={ "name": "Benjamin M. Gyori", - "email": "benjamin_gyori@hms.harvard.edu", + "email": "b.gyori@northeastern.edu", }, license_info={ "name": "BSD-2-Clause license", diff --git a/mira/examples/chime.py b/mira/examples/chime.py index 95151cdf0..749b22045 100644 --- a/mira/examples/chime.py +++ b/mira/examples/chime.py @@ -1,4 +1,4 @@ -"""CHIME SVIIvR.""" +"""CHIME SVIIvR epi model.""" from mira.metamodel import NaturalConversion, ControlledConversion, \ TemplateModel diff --git a/mira/examples/decapodes/decapodes_examples.py b/mira/examples/decapodes/decapodes_examples.py index 8580ef8a5..1ca63ab74 100644 --- a/mira/examples/decapodes/decapodes_examples.py +++ b/mira/examples/decapodes/decapodes_examples.py @@ -20,48 +20,101 @@ def get_ice_dynamics_example() -> Decapode: - """Return the ice dynamics decapode example""" + """Return the ice dynamics decapode example + + Returns + ------- + : + The ice dynamics example as a Decapode object + """ res_json = requests.get(ICE_DYNAMICS_EXAMPLE_JSON_URL).json() return process_decapode(res_json) def get_ice_decapode_json(): + """Return the ice dynamics decapode example as json + + Returns + ------- + : JSON + The ice dynamics example as a json object + """ return requests.get(ICE_DYNAMICS_EXAMPLE_JSON_URL).json() def get_oscillator_decapode() -> Decapode: - """Return the oscillator decapode example""" + """Return the oscillator decapode example + + Example from + + Returns + ------- + : + The oscillator example as a Decapode object + """ with open(DECAPODE_OSCILLATOR) as f: decapode_osc_json = json.load(f) return process_decapode(decapode_osc_json) def get_oscillator_decapode_json(): + """Return the oscillator decapode example as json + + Returns + ------- + : JSON + The oscillator example as a json object + """ with open(DECAPODE_OSCILLATOR) as f: return json.load(f) def get_friction_decapode() -> Decapode: - """Return the friction decaexpr example""" + """Return the friction decaexpr example + + Returns + ------- + : + The friction example as a Decapode object + """ with open(DECAPODE_FRICTION) as f: decapode_friction_json = json.load(f) return process_decapode(decapode_friction_json) def get_friction_decapode_json(): + """Return the friction decaexpr example as json + + Returns + ------- + : JSON + The friction example as a json object + """ with open(DECAPODE_FRICTION) as f: return json.load(f) def get_oscillator_decaexpr() -> Decapode: - """Return the oscillator decaexpr example""" + """Return the oscillator decaexpr example + + Returns + ------- + : + The oscillator example as a Decapode object + """ with open(DECAEXPR_OSCILLATOR) as f: decaexpr_osc_json = json.load(f) return process_decaexpr(decaexpr_osc_json) def get_friction_decaexpr() -> Decapode: - """Return the friction decaexpr example""" + """Return the friction decaexpr example + + Returns + ------- + : + The friction example as a Decapode object + """ with open(DECAEXPR_FRICTION) as f: decaexpr_friction_json = json.load(f) return process_decaexpr(decaexpr_friction_json) diff --git a/mira/examples/decapodes/decapodes_vs_decaexpr_composite/README.md b/mira/examples/decapodes/decapodes_vs_decaexpr_composite/README.md index e2a873747..42b75aed9 100644 --- a/mira/examples/decapodes/decapodes_vs_decaexpr_composite/README.md +++ b/mira/examples/decapodes/decapodes_vs_decaexpr_composite/README.md @@ -1,6 +1,8 @@ # Decapodes vs DecaExpr Composite models -This directory contains the components of the composite model example at +This directory, +[mira/examples/decapodes/decapodes_vs_decaexpr_composite](https://github.com/gyorilab/mira/tree/main/mira/examples/decapodes/decapodes_vs_decaexpr_composite) +, contains the components of the composite model example at https://algebraicjulia.github.io/SyntacticModels.jl/dev/generated/composite_models_examples/ ## DecaExpr JSON files diff --git a/mira/examples/mech_bayes.py b/mira/examples/mech_bayes.py index 24741151e..46c7b510a 100644 --- a/mira/examples/mech_bayes.py +++ b/mira/examples/mech_bayes.py @@ -1,4 +1,4 @@ -"""A curated model describing the Msch Bayes model from the paper: +"""A curated model describing the Mech Bayes model from the paper: https://www.medrxiv.org/content/10.1101/2020.12.22.20248736v2 https://github.com/dsheldon/mechbayes diff --git a/mira/metamodel/comparison.py b/mira/metamodel/comparison.py index 0a5903429..d8b940940 100644 --- a/mira/metamodel/comparison.py +++ b/mira/metamodel/comparison.py @@ -5,7 +5,7 @@ from collections import defaultdict from itertools import combinations, count, product from typing import Literal, Optional, Mapping, List, Tuple, Dict, Callable, \ - Union + Union, Set import networkx as nx import sympy @@ -113,7 +113,20 @@ class Config: ) def get_similarity_score(self, model1_id: int, model2_id: int) -> float: - """Get the similarity score of the model comparison""" + """Get the similarity score of the model comparison + + Parameters + ---------- + model1_id : + The id of the first model + model2_id : + The id of the second model + + Returns + ------- + : + The similarity score + """ # Get all concept nodes for each model model1_concept_nodes = set() @@ -168,7 +181,13 @@ def get_similarity_score(self, model1_id: int, model2_id: int) -> float: return concept_similarity_score def get_similarity_scores(self): - """Get the similarity scores for all model comparisons""" + """Get the similarity scores for all model comparisons + + Returns + ------- + : + A list of dictionaries with the model ids and the similarity score + """ scores = [] for i, j in combinations(range(len(self.template_models)), 2): scores.append({ @@ -183,6 +202,20 @@ def from_template_models( template_models: List[TemplateModel], refinement_func: Callable[[str, str], bool] ) -> "ModelComparisonGraphdata": + """Create a ModelComparisonGraphdata from a list of TemplateModels + + Parameters + ---------- + template_models : + The list of TemplateModels to compare + refinement_func : + The refinement function to use when comparing concepts + + Returns + ------- + : + The ModelComparisonGraphdata + """ return TemplateModelComparison( template_models, refinement_func ).model_comparison @@ -197,6 +230,15 @@ def __init__( template_models: List[TemplateModel], refinement_func: Callable[[str, str], bool] ): + """Create a ModelComparisonGraphdata from a list of TemplateModels + + Parameters + ---------- + template_models : + The list of TemplateModels to compare + refinement_func : + The refinement function to use when comparing concepts + """ # Todo: Add more identifiable ID to template model than index? if len(template_models) < 2: raise ValueError("Need at least two models to make comparison") @@ -278,7 +320,7 @@ def _add_inter_model_edges( ) def compare_models(self): - """Compare TemplateModels and return a graph of the differences""" + """Run model comparison""" for model_id, template_model in self.template_models.items(): self._add_template_model(model_id, template_model) @@ -371,6 +413,27 @@ def __init__( tag2_color: str = "blue", merge_color: str = "red", ): + """Create a TemplateModelDelta + + Parameters + ---------- + template_model1 : + The first template model + template_model2 : + The second template model + refinement_function : + The refinement function to use when comparing concepts + tag1 : + The tag for the first template model. Default: "1" + tag2 : + The tag for the second template model. Default: "2" + tag1_color : + The color for the first template model. Default: "orange" + tag2_color : + The color for the second template model. Default: "blue" + merge_color : + The color for the merged template model. Default: "red" + """ self.refinement_func = refinement_function self.template_model1 = template_model1 self.templ1_graph = template_model1.generate_model_graph() @@ -657,6 +720,11 @@ def for_jupyter( string. Example: "args="-Nshape=box -Edir=forward -Ecolor=red" kwargs : Keyword arguments to pass to IPython.display.Image + + Returns + ------- + : + The IPython Image object """ from IPython.display import Image @@ -689,15 +757,42 @@ class RefinementClosure: >>> rc = RefinementClosure(get_transitive_closure_web()) >>> rc.is_ontological_child('doid:0080314', 'bfo:0000016') """ - def __init__(self, transitive_closure): + def __init__(self, transitive_closure: Set[Tuple[str, str]]): + """Initialize the RefinementClosure + + Parameters + ---------- + transitive_closure : + The transitive closure of the refinement relationship + """ self.transitive_closure = transitive_closure def is_ontological_child(self, child_curie: str, parent_curie: str) -> bool: + """Check if the child is a refinement of the parent + + Parameters + ---------- + child_curie : + The child curie + parent_curie : + The parent curie + + Returns + ------- + : + True if the child is a refinement of the parent, False otherwise + """ return (child_curie, parent_curie) in self.transitive_closure -def get_dkg_refinement_closure(): - """Return a refinement closure from the DKG""" +def get_dkg_refinement_closure() -> RefinementClosure: + """Return a refinement closure from the DKG + + Returns + ------- + : + The refinement closure + """ # Import here to avoid dependency upon module import from mira.dkg.web_client import get_transitive_closure_web rc = RefinementClosure(get_transitive_closure_web()) diff --git a/mira/metamodel/decapodes.py b/mira/metamodel/decapodes.py index 0755cca87..cefd31d35 100644 --- a/mira/metamodel/decapodes.py +++ b/mira/metamodel/decapodes.py @@ -1,5 +1,12 @@ -__all__ = ["Decapode", "Variable", "TangentVariable", "Summation", - "Op1", "Op2", "RootVariable"] +__all__ = [ + "Decapode", + "Variable", + "TangentVariable", + "Summation", + "Op1", + "Op2", + "RootVariable", +] import copy from collections import defaultdict @@ -16,31 +23,56 @@ def expand_variable(variable, var_produced_map): if not var_prod: return sympy.Symbol(variable.name) elif isinstance(var_prod, Op1): - return sympy.Function(var_prod.function_str)(expand_variable( - var_prod.src, var_produced_map)) + return sympy.Function(var_prod.function_str)( + expand_variable(var_prod.src, var_produced_map) + ) elif isinstance(var_prod, Op2): arg1 = expand_variable(var_prod.proj1, var_produced_map) arg2 = expand_variable(var_prod.proj2, var_produced_map) - if var_prod.function_str == '/': + if var_prod.function_str == "/": return arg1 / arg2 - elif var_prod.function_str == '*': + elif var_prod.function_str == "*": return arg1 * arg2 - elif var_prod.function_str == '+': + elif var_prod.function_str == "+": return arg1 + arg2 - elif var_prod.function_str == '-': + elif var_prod.function_str == "-": return arg1 - arg2 - elif var_prod.function_str == '^': - return arg1 ** arg2 + elif var_prod.function_str == "^": + return arg1**arg2 else: return sympy.Function(var_prod.function_str)(arg1, arg2) elif isinstance(var_prod, Summation): - args = [expand_variable(summand, var_produced_map) - for summand in var_prod.summands] + args = [ + expand_variable(summand, var_produced_map) + for summand in var_prod.summands + ] return sympy.Add(*args) class Decapode: + """ + MIRA's internal representation of a decapode compute graph or decaexpr + JSON. + """ + def __init__(self, variables, op1s, op2s, summations, tangent_variables): + """ + Create a Decapode based off multiple mappings of different parts of + a Decapode. + + Parameters + ---------- + variables : Dict[int,Variable] + Mapping of Variables. + op1s : Dict[int,Op1] + Mapping of Op1s (Operation 1s). + op2s : Dict[int,Op2] + Mapping of Op2s (Operation 2s). + summations : Dict[int,Summation] + Mapping of Summations. + tangent_variables : Dict[int,TangentVariable] + Mapping of TangentVariables. + """ self.variables = variables self.op1s = op1s self.op2s = op2s @@ -49,8 +81,11 @@ def __init__(self, variables, op1s, op2s, summations, tangent_variables): var_produced_map = {} root_variable_map = defaultdict(list) - for ops, res_attr in ((self.op1s, 'tgt'), (self.op2s, 'res'), - (self.summations, 'sum')): + for ops, res_attr in ( + (self.op1s, "tgt"), + (self.op2s, "res"), + (self.summations, "sum"), + ): for op_id, op in ops.items(): produced_var = getattr(op, res_attr) if produced_var.id not in var_produced_map: @@ -68,34 +103,57 @@ def __init__(self, variables, op1s, op2s, summations, tangent_variables): var = RootVariable(var_id, var.type, var.name, var.identifiers) temp_var_map = copy.deepcopy(var_produced_map) temp_var_map[var_id] = root_variable_map[var_id][0] - var.expression[0] = expand_variable(var.get_variable(), - temp_var_map) + var.expression[0] = expand_variable( + var.get_variable(), temp_var_map + ) temp_var_map = copy.deepcopy(var_produced_map) temp_var_map[var_id] = root_variable_map[var_id][1] - var.expression[1] = expand_variable(var.get_variable(), - temp_var_map) + var.expression[1] = expand_variable( + var.get_variable(), temp_var_map + ) new_vars[var_id] = var self.update_vars(new_vars) def update_vars(self, variables): self.variables = variables - for ops, var_args in ((self.op1s, ('src', 'tgt')), - (self.op2s, ('proj1', 'proj2', 'res')), - (self.summations, ('summands', 'sum')), - (self.tangent_variables, ('incl_var',))): + for ops, var_args in ( + (self.op1s, ("src", "tgt")), + (self.op2s, ("proj1", "proj2", "res")), + (self.summations, ("summands", "sum")), + (self.tangent_variables, ("incl_var",)), + ): for op in ops.values(): for var_arg in var_args: var_attr = getattr(op, var_arg) if isinstance(var_attr, Variable): setattr(op, var_arg, variables[var_attr.id]) elif isinstance(var_attr, list): - setattr(op, var_arg, [variables[var.id] - for var in var_attr]) + setattr( + op, var_arg, [variables[var.id] for var in var_attr] + ) # TODO: Inherit from Concept? @dataclass class Variable: + """ + Dataclass that represents a variable in MIRA's internal representation of + a Decapode. + + Attributes + ---------- + id : int + The id of the tangent variable + type: str + The type of the variable. + name : str + The name of the variable. + expression : sympy.Expr + The expression of the variable. + identifiers : Mapping[str,str] + The mapping of namespaces to identifiers associated with the Variable. + """ + id: int type: str name: str @@ -105,25 +163,62 @@ class Variable: @dataclass class RootVariable(Variable): + """ + Dataclass that represents a variable that is the output of a unary ( + derivative) operation and the output of a series of unary and binary + operations as well. + + Attributes + ---------- + expression : list[sympy.Expr] + A list containing both expressions associated with a RootVariable: + One expression built up from a unary operation (derivative) and one + built up from a series of unary and binary operations. + """ + expression: List[sympy.Expr] = field(default_factory=lambda: [None, None]) def get_variable(self): return Variable( - self.id, - self.type, - self.name, - identifiers=self.identifiers + self.id, self.type, self.name, identifiers=self.identifiers ) @dataclass class TangentVariable: + """ + Dataclass that represents a tangent variable in MIRA's internal + representation of a Decapode. + + Attributes + ---------- + id : int + The id of the tangent variable. + incl_var : Variable + The variable that is the result of a derivative operation associated + with the tangent variable. + """ + id: int incl_var: Variable @dataclass class Summation: + """ + Dataclass that represents a summation in MIRA's internal representation + of a decapode. + + Attributes + ---------- + id : int + The id of the summation. + summands : list[Variable] + A list of Variables that are a part of the summation. + sum : Variable + The Variable that is the result of the summation. + """ + id: int summands: List[Variable] sum: Variable @@ -131,6 +226,22 @@ class Summation: @dataclass class Op1: + """ + Dataclass that represents unary operations in MIRA's internal + representation of a decapode. + + Attributes + ---------- + id : int + The id of the operation. + src : Variable + The Variable that is the source of the operation. + tgt : Variable + The Variable that is the target of the operation. + function_str : str + The operator of the operation. + """ + id: int src: Variable tgt: Variable @@ -139,6 +250,24 @@ class Op1: @dataclass class Op2: + """ + Dataclass that represents binary operations in MIRA's internal + representation of a decapode. + + Attributes + ---------- + id : int + The id of the operation. + proj1 : Variable + The Variable that is the first input to the operation. + proj2 : Variable + The Variable that is the second input to the operation. + res : Variable + The variable that is the result of the operation. + function_str : str + The operator of the operation. + """ + id: int proj1: Variable proj2: Variable diff --git a/mira/metamodel/io.py b/mira/metamodel/io.py index e3f0c5121..09bdaa2ef 100644 --- a/mira/metamodel/io.py +++ b/mira/metamodel/io.py @@ -1,3 +1,4 @@ +"""Input/output functions for metamodels.""" __all__ = ["model_from_json_file", "model_to_json_file", "expression_to_mathml", "mathml_to_expression"] @@ -42,6 +43,20 @@ def expression_to_mathml(expression: sympy.Expr, *args, **kwargs) -> str: Here we pay attention to not style underscores and numeric suffixes in special ways. + + Parameters + ---------- + expression : + A sympy expression to convert. + args : list + Additional arguments to pass to sympy.mathml. + kwargs : dict + Additional keyword arguments to pass to sympy.mathml. + + Returns + ------- + : + A MathML string representing the sympy expression. """ if isinstance(expression, SympyExprStr): expression = expression.args[0] @@ -58,7 +73,23 @@ def expression_to_mathml(expression: sympy.Expr, *args, **kwargs) -> str: def mathml_to_expression(xml_str: str) -> sympy.Expr: - """Convert a MathML string to a sympy expression.""" + """Convert a MathML string to a sympy expression. + + Parameters + ---------- + xml_str : + A MathML string. + + Returns + ------- + : + A sympy expression. + + Notes + ----- + This function is a wrapper around the SBMLMathMLParser class from the + sbmlmath package, which has to be installed. + """ from sbmlmath import SBMLMathMLParser template = """ diff --git a/mira/metamodel/ops.py b/mira/metamodel/ops.py index 58382a692..db3e38eac 100644 --- a/mira/metamodel/ops.py +++ b/mira/metamodel/ops.py @@ -135,7 +135,9 @@ def stratify( concept_names_map = template_model.get_concepts_name_map() concept_names = set(concept_names_map.keys()) + # List of new templates templates = [] + # Counter to keep track of how many times a parameter has been stratified params_count = Counter() # Figure out excluded concepts @@ -316,9 +318,37 @@ def stratify( return new_model -def rewrite_rate_law(template_model: TemplateModel, old_template: Template, - new_template: Template, params_count, - params_to_stratify=None, params_to_preserve=None): +def rewrite_rate_law( + template_model: TemplateModel, + old_template: Template, + new_template: Template, + params_count: Counter, + params_to_stratify: Optional[Collection[str]] = None, + params_to_preserve: Optional[Collection[str]] = None, +): + """Rewrite the rate law of a template based on a new template. + + This function is used in the context of stratification. + + Parameters + ---------- + template_model : + The unstratified template model containing the templates. + old_template : + The original template. + new_template : + The new template. One of the templates created by stratification of + ``old_template``. + params_count : + A counter that keeps track of how many times a parameter has been + stratified. + params_to_stratify : + A list of parameters to stratify. If none given, will stratify all + parameters. + params_to_preserve : + A list of parameters to preserve. If none given, will stratify all + parameters. + """ # Rewrite the rate law by substituting new symbols corresponding # to the stratified controllers in for the originals rate_law = old_template.rate_law @@ -401,7 +431,7 @@ def simplify_rate_laws(template_model: TemplateModel): return template_model -def aggregate_parameters(template_model, exclude=None): +def aggregate_parameters(template_model: TemplateModel) -> TemplateModel: """Return a template model after aggregating parameters for mass-action rate laws. @@ -409,8 +439,6 @@ def aggregate_parameters(template_model, exclude=None): ---------- template_model : A template model whose rate laws will be aggregated. - exclude : - A list of parameters to exclude from aggregation. Returns ------- @@ -541,8 +569,28 @@ def simplify_rate_law(template: Template, return new_templates -def get_term_roles(term, template, parameters): - """Return terms in a rate law by role.""" +def get_term_roles( + term, + template: Template, + parameters: Mapping[str, Parameter] +) -> Mapping[str, List[str]]: + """Return terms in a rate law by role. + + Parameters + ---------- + term : + A sympy expression. + template : + A template. + parameters : + A dict of parameters in the template model, needed to interpret + the semantics of rate laws. + + Returns + ------- + : + A dict of lists of symbols in the term by role. + """ term_roles = defaultdict(list) for symbol in term.free_symbols: if symbol.name in parameters: @@ -557,7 +605,7 @@ def get_term_roles(term, template, parameters): def counts_to_dimensionless(tm: TemplateModel, counts_unit: str, - norm_factor: float): + norm_factor: float) -> TemplateModel: """Convert all quantities using a given counts unit to dimensionless units. Parameters @@ -624,9 +672,19 @@ def counts_to_dimensionless(tm: TemplateModel, return tm -def deactivate_templates(template_model: TemplateModel, - condition: Callable[[Template], bool]): - """Deactivate templates that satisfy a given condition.""" +def deactivate_templates( + template_model: TemplateModel, + condition: Callable[[Template], bool] +): + """Deactivate templates that satisfy a given condition. + + Parameters + ---------- + template_model : + A template model. + condition : + A function that takes a template and returns a boolean. + """ for template in template_model.templates: if condition(template): template.deactivate() diff --git a/mira/metamodel/schema.json b/mira/metamodel/schema.json index 9788bd750..1ed3a202c 100644 --- a/mira/metamodel/schema.json +++ b/mira/metamodel/schema.json @@ -77,15 +77,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" } } @@ -102,15 +105,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -123,16 +129,35 @@ "type": "string" }, "controller": { - "$ref": "#/definitions/Concept" + "title": "Controller", + "description": "The controller of the conversion.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "subject": { - "$ref": "#/definitions/Concept" + "title": "Subject", + "description": "The subject of the conversion.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "outcome": { - "$ref": "#/definitions/Concept" + "title": "Outcome", + "description": "The outcome of the conversion.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the conversion.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -152,15 +177,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -174,19 +202,33 @@ }, "controllers": { "title": "Controllers", + "description": "The controllers of the conversion.", "type": "array", "items": { "$ref": "#/definitions/Concept" } }, "subject": { - "$ref": "#/definitions/Concept" + "title": "Subject", + "description": "The subject of the conversion.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "outcome": { - "$ref": "#/definitions/Concept" + "title": "Outcome", + "description": "The outcome of the conversion.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the conversion.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -206,15 +248,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -228,16 +273,24 @@ }, "controllers": { "title": "Controllers", + "description": "The controllers of the production.", "type": "array", "items": { "$ref": "#/definitions/Concept" } }, "outcome": { - "$ref": "#/definitions/Concept" + "title": "Outcome", + "description": "The outcome of the production.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the production.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -256,15 +309,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -277,13 +333,26 @@ "type": "string" }, "controller": { - "$ref": "#/definitions/Concept" + "title": "Controller", + "description": "The controller of the production.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "outcome": { - "$ref": "#/definitions/Concept" + "title": "Outcome", + "description": "The outcome of the production.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "Provenance of the template", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -302,15 +371,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -323,13 +395,26 @@ "type": "string" }, "subject": { - "$ref": "#/definitions/Concept" + "title": "Subject", + "description": "The subject of the conversion.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "outcome": { - "$ref": "#/definitions/Concept" + "title": "Outcome", + "description": "The outcome of the conversion.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the conversion.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -348,15 +433,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -369,10 +457,17 @@ "type": "string" }, "outcome": { - "$ref": "#/definitions/Concept" + "title": "Outcome", + "description": "The outcome of the production.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the production.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -390,15 +485,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -411,10 +509,17 @@ "type": "string" }, "subject": { - "$ref": "#/definitions/Concept" + "title": "Subject", + "description": "The subject of the degradation.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the degradation.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -432,15 +537,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -453,13 +561,26 @@ "type": "string" }, "controller": { - "$ref": "#/definitions/Concept" + "title": "Controller", + "description": "The controller of the degradation.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "subject": { - "$ref": "#/definitions/Concept" + "title": "Subject", + "description": "The subject of the degradation.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the degradation.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -478,15 +599,18 @@ "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -500,16 +624,24 @@ }, "controllers": { "title": "Controllers", + "description": "The controllers of the degradation.", "type": "array", "items": { "$ref": "#/definitions/Concept" } }, "subject": { - "$ref": "#/definitions/Concept" + "title": "Subject", + "description": "The subject of the degradation.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance of the degradation.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -523,20 +655,23 @@ }, "StaticConcept": { "title": "StaticConcept", - "description": "Specifies a standalone Concept.", + "description": "Specifies a standalone Concept that is not part of a process.", "type": "object", "properties": { "rate_law": { "title": "Rate Law", + "description": "The rate law for the template.", "type": "string", "example": "2*x" }, "name": { "title": "Name", + "description": "The name of the template.", "type": "string" }, "display_name": { "title": "Display Name", + "description": "The display name of the template.", "type": "string" }, "type": { @@ -549,10 +684,17 @@ "type": "string" }, "subject": { - "$ref": "#/definitions/Concept" + "title": "Subject", + "description": "The subject.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "provenance": { "title": "Provenance", + "description": "The provenance.", "type": "array", "items": { "$ref": "#/definitions/Provenance" @@ -653,11 +795,17 @@ }, "Initial": { "title": "Initial", - "description": "An initial condition.", + "description": "Represents the initial conditions for parameters present in the\nmodel.", "type": "object", "properties": { "concept": { - "$ref": "#/definitions/Concept" + "title": "Concept", + "description": "The concept associated with the initial.", + "allOf": [ + { + "$ref": "#/definitions/Concept" + } + ] }, "expression": { "title": "Expression", diff --git a/mira/metamodel/schema.py b/mira/metamodel/schema.py index 6103853e5..1a3dea2d1 100644 --- a/mira/metamodel/schema.py +++ b/mira/metamodel/schema.py @@ -15,7 +15,13 @@ def get_json_schema(): - """Get the JSON schema for MIRA.""" + """Get the JSON schema for MIRA. + + Returns + ------- + : JSON + The JSON schema for MIRA. + """ rv = { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/indralab/mira/main/mira/metamodel/schema.json", diff --git a/mira/metamodel/search.py b/mira/metamodel/search.py index bc851e950..4cd34d097 100644 --- a/mira/metamodel/search.py +++ b/mira/metamodel/search.py @@ -5,8 +5,26 @@ from .template_model import TemplateModel, model_has_grounding -def find_models_with_grounding(template_models: Mapping[str, TemplateModel], - prefix: str, identifier: str) -> Mapping[str, TemplateModel]: - """Filter a dict of models to ones containing a given grounding in any role.""" +def find_models_with_grounding( + template_models: Mapping[str, TemplateModel], + prefix: str, + identifier: str +) -> Mapping[str, TemplateModel]: + """Filter a dict of models to ones containing a given grounding in any role. + + Parameters + ---------- + template_models : + A dict of template models. + prefix : + A prefix of a CURIE. + identifier : + An identifier of a CURIE. + + Returns + ------- + : + A dict of template models containing the given grounding. + """ return {k: m for k, m in template_models.items() if model_has_grounding(m, prefix, identifier)} diff --git a/mira/metamodel/template_model.py b/mira/metamodel/template_model.py index 5e942aa28..a67fdfc27 100644 --- a/mira/metamodel/template_model.py +++ b/mira/metamodel/template_model.py @@ -1,5 +1,14 @@ -__all__ = ["Annotations", "TemplateModel", "Initial", "Parameter", - "Distribution", "Observable", "Time", "model_has_grounding"] +__all__ = [ + "Annotations", + "TemplateModel", + "Initial", + "Parameter", + "Distribution", + "Observable", + "Time", + "model_has_grounding", + "Concept", +] import datetime import sys @@ -15,9 +24,12 @@ class Initial(BaseModel): - """An initial condition.""" + """Represents the initial conditions for parameters present in the + model.""" - concept: Concept + concept: Concept = Field( + description="The concept associated with the initial." + ) expression: SympyExprStr = Field( description="The expression for the initial." ) @@ -27,34 +39,69 @@ class Config: json_encoders = { SympyExprStr: lambda e: str(e), } - json_decoders = { - SympyExprStr: lambda e: sympy.parse_expr(e) - } + json_decoders = {SympyExprStr: lambda e: sympy.parse_expr(e)} @classmethod def from_json(cls, data: Dict[str, Any], locals_dict=None) -> "Initial": - expression_str = data.pop('expression') - concept_json = data.pop('concept') + """ + Returns an Initial from a dictionary. + + Parameters + ---------- + data : Dict[str,Any] + Mapping of Initial attributes to their values. + locals_dict : Dict[str,Any] + Mapping of string symbols to their sympy equivalent. + + Returns + ------- + : + The newly created initial. + """ + expression_str = data.pop("expression") + concept_json = data.pop("concept") # Get Concept concept = Concept.from_json(concept_json) # We now create the expression by parsing the expressions string # with respect to a dict of local symbols - expression = safe_parse_expr(expression_str, - local_dict=locals_dict) - return cls(concept=concept, - expression=SympyExprStr(expression)) + expression = safe_parse_expr(expression_str, local_dict=locals_dict) + return cls(concept=concept, expression=SympyExprStr(expression)) def substitute_parameter(self, name, value): - """Substitute a parameter value into the observable expression.""" + """ + Substitute a parameter value into the initial expression. + + Parameters + ---------- + name : str + The name of the parameter to substitute. + value : + The value to substitute. + """ self.expression = self.expression.subs(sympy.Symbol(name), value) def get_parameter_names(self, known_param_names) -> Set[str]: - """Get the names of all parameters in the expression.""" - return {str(s) for s in self.expression.free_symbols} & set(known_param_names) + """ + Get the names of all parameters in the expression. + + Parameters + ---------- + known_param_names : list[str] + List of parameter names. + + Returns + ------- + : + The set of parameter names. + """ + return {str(s) for s in self.expression.free_symbols} & set( + known_param_names + ) class Distribution(BaseModel): """A distribution of values for a parameter.""" + type: str = Field( description="The type of distribution, e.g. 'uniform', 'normal', etc." ) @@ -65,11 +112,14 @@ class Distribution(BaseModel): class Parameter(Concept): """A Parameter is a special type of Concept that carries a value.""" + value: Optional[float] = Field( - default_factory=None, description="Value of the parameter.") + default_factory=None, description="Value of the parameter." + ) distribution: Optional[Distribution] = Field( - default_factory=None, description="A distribution of values for the parameter." + default_factory=None, + description="A distribution of values for the parameter.", ) @@ -86,31 +136,51 @@ class Config: json_encoders = { SympyExprStr: lambda e: str(e), } - json_decoders = { - SympyExprStr: lambda e: safe_parse_expr(e) - } + json_decoders = {SympyExprStr: lambda e: safe_parse_expr(e)} expression: SympyExprStr = Field( description="The expression for the observable." ) def substitute_parameter(self, name, value): - """Substitute a parameter value into the observable expression.""" + """ + Substitute a parameter value into the observable expression. + + Parameters + ---------- + name : str + The name of the parameter to substitute. + value : + The value to substitute. + """ self.expression = self.expression.subs(sympy.Symbol(name), value) def get_parameter_names(self, known_param_names) -> Set[str]: - """Get the names of all parameters in the expression.""" - return {str(s) for s in self.expression.free_symbols} & set(known_param_names) + """ + Get the names of all parameters in the expression. + + Parameters + ---------- + known_param_names : list[str] + List of parameter names. + + Returns + ------- + : + The set of parameter names. + """ + return {str(s) for s in self.expression.free_symbols} & set( + known_param_names + ) class Time(BaseModel): """A special type of Concept that represents time.""" + name: str = Field( default="t", description="The symbol of the time variable in the model." ) - units: Optional[Unit] = Field( - description="The units of the time variable." - ) + units: Optional[Unit] = Field(description="The units of the time variable.") class Author(BaseModel): @@ -129,7 +199,7 @@ class Annotations(BaseModel): name: Optional[str] = Field( description="A human-readable label for the model", - example="SIR model of scenarios of COVID-19 spread in CA and NY" + example="SIR model of scenarios of COVID-19 spread in CA and NY", ) # identifiers: Dict[str, str] = Field( # description="Structured identifiers corresponding to the model artifact " @@ -154,7 +224,7 @@ class Annotations(BaseModel): "time series data for a particular region. Capable of measuring and " "forecasting the impacts of social distancing, these models highlight the " "dangers of relaxing nonpharmaceutical public health interventions in the " - "absence of a vaccine or antiviral therapies." + "absence of a vaccine or antiviral therapies.", ) license: Optional[str] = Field( description="Information about the licensing of the model artifact. " @@ -243,7 +313,6 @@ class Annotations(BaseModel): example=[ "ncbitaxon:9606", ], - ) model_types: List[str] = Field( default_factory=list, @@ -265,41 +334,40 @@ class TemplateModel(BaseModel): templates: List[SpecifiedTemplate] = Field( ..., description="A list of any child class of Templates" ) - parameters: Dict[str, Parameter] = \ - Field(default_factory=dict, - description="A dict of parameter values where keys correspond " - "to how the parameter appears in rate laws.") - initials: Dict[str, Initial] = \ - Field(default_factory=dict, - description="A dict of initial condition values where keys" - "correspond to concept names they apply to.") - - observables: Dict[str, Observable] = \ - Field(default_factory=dict, - description="A list of observables that are readouts " - "from the model.") - - annotations: Optional[Annotations] = \ - Field( - default_factory=None, - description="A structure containing model-level annotations. " - "Note that all annotations are optional.", - ) + parameters: Dict[str, Parameter] = Field( + default_factory=dict, + description="A dict of parameter values where keys correspond " + "to how the parameter appears in rate laws.", + ) + initials: Dict[str, Initial] = Field( + default_factory=dict, + description="A dict of initial condition values where keys" + "correspond to concept names they apply to.", + ) - time: Optional[Time] = \ - Field( - default_factory=None, - description="A structure containing time-related annotations. " - "Note that all annotations are optional.", - ) + observables: Dict[str, Observable] = Field( + default_factory=dict, + description="A list of observables that are readouts " + "from the model.", + ) + + annotations: Optional[Annotations] = Field( + default_factory=None, + description="A structure containing model-level annotations. " + "Note that all annotations are optional.", + ) + + time: Optional[Time] = Field( + default_factory=None, + description="A structure containing time-related annotations. " + "Note that all annotations are optional.", + ) class Config: json_encoders = { SympyExprStr: lambda e: str(e), } - json_decoders = { - SympyExprStr: lambda e: safe_parse_expr(e) - } + json_decoders = {SympyExprStr: lambda e: safe_parse_expr(e)} def get_parameters_from_rate_law(self, rate_law) -> Set[str]: """Given a rate law, find its elements that are model parameters. @@ -310,13 +378,13 @@ def get_parameters_from_rate_law(self, rate_law) -> Set[str]: Parameters ---------- - rate_law : - A sympy expression or symbol, whose names are extracted + rate_law : sympy.Symbol | sympy.Expr + A sympy expression or symbol, whose names are extracted. Returns ------- : - A set of parameter names (as strings) + A set of parameter names (as strings). """ if rate_law is None: return set() @@ -327,14 +395,25 @@ def get_parameters_from_rate_law(self, rate_law) -> Set[str]: params.add(rate_law.name) # There are many sympy classes that have args that can occur here # so it's better to check for the presence of args - elif not hasattr(rate_law, 'args'): - raise ValueError(f"Rate law is of invalid type {type(rate_law)}: {rate_law}") + elif not hasattr(rate_law, "args"): + raise ValueError( + f"Rate law is of invalid type {type(rate_law)}: {rate_law}" + ) else: for arg in rate_law.args: params |= self.get_parameters_from_rate_law(arg) return params def update_parameters(self, parameter_dict): + """ + Update parameter values. + + Parameters + ---------- + parameter_dict : Dict[str,float] + Mapping of parameter name to value. + + """ for k, v in parameter_dict.items(): if k in self.parameters: self.parameters[k].value = v @@ -355,27 +434,49 @@ def eliminate_unused_parameters(self): if k not in used_parameters: self.parameters.pop(k) - def eliminate_duplicate_parameter(self, redundant_parameter, - preserved_parameter): + def eliminate_duplicate_parameter( + self, redundant_parameter, preserved_parameter + ): """Eliminate a duplicate parameter from the model. This happens when there are two redundant parameters only one of which is actually used in the model. This function removes the redundant parameter and updates the rate laws to use the preserved parameter. + + Parameters + ---------- + redundant_parameter : str + The name of the parameter to remove. + preserved_parameter : str + The new name of the parameter to preserve. """ # Update the rate laws for template in self.templates: - template.update_parameter_name(redundant_parameter, - preserved_parameter) + template.update_parameter_name( + redundant_parameter, preserved_parameter + ) self.parameters.pop(redundant_parameter) @classmethod def from_json(cls, data) -> "TemplateModel": - local_symbols = {p: sympy.Symbol(p) for p in data.get('parameters', [])} - for template_dict in data.get('templates', []): + """ + Return a template model from a dictionary + + Parameters + ---------- + data : Dict[str,Any] + Mapping of template model attributes to their values. + + Returns + ------- + : + Returns the newly created template model. + """ + local_symbols = {p: sympy.Symbol(p) for p in data.get("parameters", [])} + for template_dict in data.get("templates", []): # We need to figure out the template class based on the type # entry in the data - template_cls = getattr(sys.modules[__name__], template_dict['type']) + template_cls = getattr(sys.modules[__name__], template_dict["type"]) for concept_key in template_cls.concept_keys: # Note the special handling here for list-like vs single # concepts @@ -384,12 +485,15 @@ def from_json(cls, data) -> "TemplateModel": if not isinstance(concept_data, list): concept_data = [concept_data] for concept_dict in concept_data: - if concept_dict.get('name'): - local_symbols[concept_dict.get('name')] = \ - sympy.Symbol(concept_dict.get('name')) + if concept_dict.get("name"): + local_symbols[ + concept_dict.get("name") + ] = sympy.Symbol(concept_dict.get("name")) # We can now use these symbols to deserialize rate laws - templates = [Template.from_json(template, rate_symbols=local_symbols) - for template in data["templates"]] + templates = [ + Template.from_json(template, rate_symbols=local_symbols) + for template in data["templates"] + ] #: A lookup from concept name in the model to the full #: concept object to be used for preparing initial values @@ -401,11 +505,11 @@ def from_json(cls, data) -> "TemplateModel": # Handle parameters parameters = { par_key: Parameter.from_json(par_dict) - for par_key, par_dict in data.get('parameters', {}).items() + for par_key, par_dict in data.get("parameters", {}).items() } initials = {} - for name, value in data.get('initials', {}).items(): + for name, value in data.get("initials", {}).items(): if isinstance(value, float): # If the data is just a float, upgrade it to # a :class:`Initial` instance @@ -416,20 +520,31 @@ def from_json(cls, data) -> "TemplateModel": else: # If the data is not a float, assume it's JSON # for a :class:`Initial` instance and parse it to Initial - local_symbols = {p.name: sympy.Symbol(p.name) - for p in parameters.values()} - initials[name] = Initial.from_json(value, - locals_dict=local_symbols) + local_symbols = { + p.name: sympy.Symbol(p.name) for p in parameters.values() + } + initials[name] = Initial.from_json( + value, locals_dict=local_symbols + ) - return cls(templates=templates, - parameters=parameters, - initials=initials, - annotations=data.get('annotations')) + return cls( + templates=templates, + parameters=parameters, + initials=initials, + annotations=data.get("annotations"), + ) def generate_model_graph(self) -> nx.DiGraph: + """ + Generate a graph based off the template model. + + Returns + ------- + : + A graph + """ graph = nx.DiGraph() for template in self.templates: - # Add node for template itself node_id = get_template_graph_key(template) graph.add_node( @@ -443,7 +558,9 @@ def generate_model_graph(self) -> nx.DiGraph: # Add in/outgoing nodes for the concepts of this template for role, concepts in template.get_concepts_by_role().items(): - for concept in concepts if isinstance(concepts, list) else [concepts]: + for concept in ( + concepts if isinstance(concepts, list) else [concepts] + ): # Note: this includes the node's name as well as its # grounding concept_key = get_concept_graph_key(concept) @@ -467,8 +584,7 @@ def generate_model_graph(self) -> nx.DiGraph: color="orange", concept_identity_key=concept_identity_key, ) - role_label = "controller" if role == "controllers" \ - else role + role_label = "controller" if role == "controllers" else role if role_label in {"controller", "subject"}: source, target = concept_key, node_id else: @@ -478,29 +594,58 @@ def generate_model_graph(self) -> nx.DiGraph: return graph def draw_graph( - self, path: str, prog: str = "dot", args: str = "", format: Optional[str] = None + self, + path: str, + prog: str = "dot", + args: str = "", + format: Optional[str] = None, ): - """Draw a pygraphviz graph of the TemplateModel + """Draw a pygraphviz graph of the TemplateModel. Parameters ---------- path : - The path to the output file + The path to the output file. prog : The graphviz layout program to use, such as "dot", "neato", etc. format : - Set the file format explicitly + Set the file format explicitly. args : Additional arguments to pass to the graphviz bash program as a - string. Example: args="-Nshape=box -Edir=forward -Ecolor=red" + string. Example: args="-Nshape=box -Edir=forward -Ecolor=red". """ # draw graph graph = self.generate_model_graph() agraph = nx.nx_agraph.to_agraph(graph) agraph.draw(path, format=format, prog=prog, args=args) - def draw_jupyter(self, path: str = "model.png", prog: str = "dot", args: str = "", format: Optional[str] = None): - """Display in jupyter.""" + def draw_jupyter( + self, + path: str = "model.png", + prog: str = "dot", + args: str = "", + format: Optional[str] = None, + ): + """ + Display in jupyter. + + Parameters + ---------- + path : + The path to the output file. + prog : + The graphviz layout program to use, such as "dot", "neato", etc. + format : + Set the file format explicitly. + args : + Additional arguments to pass to the graphviz bash program as a + string. Example: args="-Nshape=box -Edir=forward -Ecolor=red". + + Returns + ------- + : Image + The image of the graph. + """ from IPython.display import Image self.draw_graph(path=path, prog=prog, args=args, format=format) @@ -508,47 +653,85 @@ def draw_jupyter(self, path: str = "model.png", prog: str = "dot", args: str = " return Image(path) def graph_as_json(self) -> Dict: - """Serialize the TemaplateModel graph as node-link data""" + """ + Serialize the TemplateModel graph as node-link data. + + Returns + ------- + : + The node-link data as a dictionary. + """ graph = self.generate_model_graph() return nx.node_link_data(graph) def print_params_table(self): + """Print the table full of parameters.""" import tabulate + contexts = set() for key, param in self.parameters.items(): contexts |= set(param.context.keys()) - header = ['name', 'identifier'] + sorted(contexts) + header = ["name", "identifier"] + sorted(contexts) rows = [header] for key, param in self.parameters.items(): - identifier_curie = ':'.join(list(param.identifiers.items())[0]) - context_entries = [param.context.get(context) - for context in sorted(contexts)] + identifier_curie = ":".join(list(param.identifiers.items())[0]) + context_entries = [ + param.context.get(context) for context in sorted(contexts) + ] rows.append([key, identifier_curie] + context_entries) - print(tabulate.tabulate(rows, headers='firstrow')) + print(tabulate.tabulate(rows, headers="firstrow")) def get_concepts_map(self): - """Return a mapping from concept keys to concepts that + """ + Return a mapping from concept keys to concepts that appear in this template model's templates. + + Returns + ------- + : Dict[str,Concept] + The mapping of concept keys to concepts that appear in this + template model's templates. """ return {concept.get_key(): concept for concept in _iter_concepts(self)} def get_concepts_name_map(self): - """Return a mapping from concept names to concepts that + """ + Return a mapping from concept names to concepts that appear in this template model's templates. + + Returns + ------- + : Dict[str,Concept] + Mapping of concept names to concepts that appear in this + template model's templates. """ return {concept.name: concept for concept in _iter_concepts(self)} def get_concept(self, name: str) -> Optional[Concept]: - """Return the first concept that has the given name.""" + """ + Return the first concept that has the given name. + + Parameters + ---------- + name : + The name to be queried for. + + Returns + ------- + : + The first concept that has the given name if it's present in the + TemplateModel. + """ names = self.get_concepts_by_name(name) if names: return names[0] return None def reset_base_names(self): - """Reset the base names of all concepts in this model to the current name.""" + """Reset the base names of all concepts in this model + to the current name.""" for template in self.templates: for concept in template.get_concepts(): concept._base_name = concept.name @@ -561,7 +744,17 @@ def get_concepts_by_name(self, name: str) -> List[Concept]: .. warning:: this could give duplicates if there are nodes with - compositional grounding + compositional grounding. + + Parameters + ---------- + name : + The name to be queried for. + + Returns + ------- + : + A list of concepts that have the given name. """ name = name.casefold() return [ @@ -571,15 +764,37 @@ def get_concepts_by_name(self, name: str) -> List[Concept]: if concept.name.casefold() == name ] - def extend(self, template_model: "TemplateModel", - parameter_mapping: Optional[Mapping[str, Parameter]] = None, - initial_mapping: Optional[Mapping[str, Initial]] = None): - """Extend this template model with another template model.""" + def extend( + self, + template_model: "TemplateModel", + parameter_mapping: Optional[Mapping[str, Parameter]] = None, + initial_mapping: Optional[Mapping[str, Initial]] = None, + ): + """ + Extend this template model with another template model. + + Parameters + ---------- + template_model : + The template model to add + parameter_mapping : + Mapping of parameter names to `Parameter` + initial_mapping : + Mapping of initial names to `Initial` + + Returns + ------- + : TemplateModel + The template model with added templates from the added + template model + """ model = self for template in template_model.templates: - model = model.add_template(template, - parameter_mapping=parameter_mapping, - initial_mapping=initial_mapping) + model = model.add_template( + template, + parameter_mapping=parameter_mapping, + initial_mapping=initial_mapping, + ) return model def add_template( @@ -588,18 +803,18 @@ def add_template( parameter_mapping: Optional[Mapping[str, Parameter]] = None, initial_mapping: Optional[Mapping[str, Initial]] = None, ) -> "TemplateModel": - """Add a template to the model + """Add a template to the model. Parameters ---------- template : - The template to add + The template to add. parameter_mapping : - A mapping from parameter names in the template to Parameter - instances in the model. + A mapping from parameter names in the template to Parameters in + the model. initial_mapping : - A mapping from concept names in the template to Initial - instances in the model + A mapping from concept names in the template to Initials in the + model. Returns ------- @@ -608,14 +823,16 @@ def add_template( """ # todo: handle adding parameters and initials if parameter_mapping is None and initial_mapping is None: - return TemplateModel(templates=self.templates + [template], - parameters=self.parameters, - initials=self.initials, - observables=self.observables, - annotations=self.annotations, - time=self.time) + return TemplateModel( + templates=self.templates + [template], + parameters=self.parameters, + initials=self.initials, + observables=self.observables, + annotations=self.annotations, + time=self.time, + ) elif parameter_mapping is None: - initials = (self.initials or {}) + initials = self.initials or {} initials.update(initial_mapping or {}) return TemplateModel( templates=self.templates + [template], @@ -626,7 +843,7 @@ def add_template( time=self.time, ) elif initial_mapping is None: - parameters = (self.parameters or {}) + parameters = self.parameters or {} parameters.update(parameter_mapping or {}) return TemplateModel( templates=self.templates + [template], @@ -637,9 +854,9 @@ def add_template( time=self.time, ) else: - initials = (self.initials or {}) + initials = self.initials or {} initials = initials.update(initial_mapping or {}) - parameters = (self.parameters or {}) + parameters = self.parameters or {} parameters.update(parameter_mapping or {}) return TemplateModel( templates=self.templates + [template], @@ -657,48 +874,81 @@ def add_transition( outcome_concept: Concept = None, rate_law_sympy: SympyExprStr = None, params_dict: Mapping = None, - mass_action_parameter: Optional[Parameter] = None + mass_action_parameter: Optional[Parameter] = None, ) -> "TemplateModel": - """Add support for Natural templates between a source and an outcome. - Multiple parameters can be added explicitly or implicitly + """ + Add a transition to a template model. Only Natural + templates between a source and an outcome are supported. + Multiple parameters can be added explicitly or implicitly. + + Parameters + ---------- + transition_name : + Name of the new transition to be added. + subject_concept : + The subject of the new transition. + outcome_concept : + The outcome of the new transition. + rate_law_sympy : + The rate law associated with the new transition. + params_dict : + Mapping of parameter attribute to their respective + values. + mass_action_parameter : + The mass action parameter that will be set to the transition's + mass action rate law if provided. + Returns + ------- + : + The new template model with the added transition. """ if subject_concept and outcome_concept: template = NaturalConversion( name=transition_name, subject=subject_concept, outcome=outcome_concept, - rate_law=rate_law_sympy + rate_law=rate_law_sympy, ) elif subject_concept and outcome_concept is None: template = NaturalDegradation( name=transition_name, subject=subject_concept, - rate_law=rate_law_sympy) + rate_law=rate_law_sympy, + ) else: template = NaturalProduction( name=transition_name, outcome=outcome_concept, - rate_law=rate_law_sympy + rate_law=rate_law_sympy, ) if params_dict and template.rate_law: # add explicitly parameters to template model for free_symbol_sympy in template.rate_law.free_symbols: free_symbol_str = str(free_symbol_sympy) if free_symbol_str in params_dict: - name = params_dict[free_symbol_str].get('display_name') - description = params_dict[free_symbol_str].get('description') - value = params_dict[free_symbol_str].get('value') - units = params_dict[free_symbol_str].get('units') - distribution = params_dict[free_symbol_str].get('distribution') - self.add_parameter(parameter_id=free_symbol_str, name=name, - description=description, - value=value, - distribution=distribution, - units_mathml=units) + name = params_dict[free_symbol_str].get("display_name") + description = params_dict[free_symbol_str].get( + "description" + ) + value = params_dict[free_symbol_str].get("value") + units = params_dict[free_symbol_str].get("units") + distribution = params_dict[free_symbol_str].get( + "distribution" + ) + self.add_parameter( + parameter_id=free_symbol_str, + name=name, + description=description, + value=value, + distribution=distribution, + units_mathml=units, + ) # If there are no explicitly defined parameters # Extract new parameters from rate laws without any other information about that parameter elif template.rate_law: - free_symbol_str = {str(symbol) for symbol in template.rate_law.free_symbols} + free_symbol_str = { + str(symbol) for symbol in template.rate_law.free_symbols + } # Remove subject name from list of free symbols if the template is not NaturalProduction if not isinstance(template, NaturalProduction): @@ -709,64 +959,133 @@ def add_transition( elif mass_action_parameter: template.set_mass_action_rate_law(mass_action_parameter.name) - pm = {mass_action_parameter.name: mass_action_parameter} if mass_action_parameter else None + pm = ( + {mass_action_parameter.name: mass_action_parameter} + if mass_action_parameter + else None + ) return self.add_template(template, parameter_mapping=pm) def substitute_parameter(self, name, value=None): - """Substitute a parameter with a value.""" + """ + Substitute a parameter with the value argument if supplied, + else substitute the parameter with the parameter's value. + + Parameters + ---------- + name : str + The name of the parameter to substitute. + value : + The value to substitute. + + + Returns + ------- + : + `None` if there does not exist a parameter with the given name. + Else not return value. + """ if name not in self.parameters: return if value is None: value = self.parameters[name].value - self.parameters = {k: v for k, v in self.parameters.items() - if k != name} + self.parameters = { + k: v for k, v in self.parameters.items() if k != name + } for template in self.templates: template.substitute_parameter(name, value) for observable in self.observables.values(): observable.substitute_parameter(name, value) - def add_parameter(self, parameter_id: str, - name: str = None, - description: str = None, - value: float = None, - distribution=None, - units_mathml: str = None): + def add_parameter( + self, + parameter_id: str, + name: str = None, + description: str = None, + value: float = None, + distribution=None, + units_mathml: str = None, + ): + """ + Add a parameter to the template model. + + Parameters + ---------- + parameter_id : + The id of the parameter. + name : + The name of the parameter. + description : + The description of the parameter. + value : + The value of the newly added parameter. + distribution : Dict[str,Any] + Dictionary of distribution attributes to their values to be + passed into the `Distribution` constructor. + units_mathml : + The unit of the parameter in mathml form. + + """ distribution = Distribution(**distribution) if distribution else None if units_mathml: units = { - 'expression': mira.metamodel.io.mathml_to_expression(units_mathml), - 'expression_mathml': units_mathml + "expression": mira.metamodel.io.mathml_to_expression( + units_mathml + ), + "expression_mathml": units_mathml, } else: units = None data = { - 'name': parameter_id, - 'display_name': name, - 'description': description, - 'value': value, - 'distribution': distribution, - 'units': units + "name": parameter_id, + "display_name": name, + "description": description, + "value": value, + "distribution": distribution, + "units": units, } parameter = Parameter(**data) self.parameters[parameter_id] = parameter def eliminate_parameter(self, name): - """Eliminate a parameter from the model by substituting 0.""" + """ + Eliminate a parameter from the model by substituting 0. + + Parameters + ---------- + name : str + The name of the parameter to be eliminated. + """ self.substitute_parameter(name, value=0) def set_parameters(self, param_dict): - """Set the parameters of this model to the values in the given dict.""" + """ + Set the parameters of this model to the values in the given dict. + + Parameters + ---------- + param_dict : Dict[str,float] + Mapping of parameter name to its new value. + + """ for name, value in param_dict.items(): if self.parameters and name in self.parameters: self.parameters[name].value = value def set_initials(self, initial_dict): - """Set the initials of this model to the values in the given dict.""" - for name, value in initial_dict.items(): + """ + Set the initials of this model to the expression in the given dict. + + Parameters + ---------- + initial_dict : Dict[str,SympyExprStr] + Mapping of initial name to its new expression. + """ + for name, expression in initial_dict.items(): if self.initials and name in self.initials: - self.initials[name].value = value + self.initials[name].expression = expression def _iter_concepts(template_model: TemplateModel): @@ -816,13 +1135,29 @@ def get_template_graph_key(template: Template) -> Tuple[str, ...]: if len(key) > 1: return tuple(key) else: - return key[0], + return (key[0],) -def model_has_grounding(template_model: TemplateModel, prefix: str, - identifier: str) -> bool: - """Return whether a model contains a given grounding in any role.""" - search_curie = f'{prefix}:{identifier}' +def model_has_grounding( + template_model: TemplateModel, prefix: str, identifier: str +) -> bool: + """ + Returns true or false if a given search curie is present within the + TemplateModel. + + Parameters + ---------- + template_model : + The TemplateModel to query. + prefix : + The prefix of the search curie. + identifier : + The identifier of the search curie. + Returns + ------- + : + """ + search_curie = f"{prefix}:{identifier}" for template in template_model.templates: for concept in template.get_concepts(): for concept_prefix, concept_id in concept.identifiers.items(): diff --git a/mira/metamodel/templates.py b/mira/metamodel/templates.py index 793784b8e..141f0aca1 100644 --- a/mira/metamodel/templates.py +++ b/mira/metamodel/templates.py @@ -1,7 +1,7 @@ """ Data models for metamodel templates. -Regenerate the JSON schema by running ``python -m mira.metamodel.templates``. +Regenerate the JSON schema by running ``python -m mira.metamodel.schema``. """ __all__ = [ "Concept", @@ -66,8 +66,14 @@ class Config(BaseModel): """Config determining how keys are generated""" - prefix_priority: List[str] - prefix_exclusions: List[str] + prefix_priority: List[str] = Field( + default_factory=list, + description="The priority list of prefixes for identifiers." + ) + prefix_exclusions: List[str] = Field( + default_factory=list, + description="The list of prefixes to exclude." + ) DEFAULT_CONFIG = Config( @@ -122,7 +128,7 @@ def with_context(self, do_rename=False, curie_to_name_map=None, **context) -> "C introduced curie_to_name_map : Use to set a name different from the context values provided in - the **context kwarg when do_rename=True. Useful if + the `**context` kwarg when do_rename=True. Useful if the context values are e.g. curies or longer names that should be shortened, like {"New York City": "nyc"}. If not provided ( default behavior), the context values will be used as names. @@ -155,7 +161,18 @@ def with_context(self, do_rename=False, curie_to_name_map=None, **context) -> "C return concept def get_curie(self, config: Optional[Config] = None) -> Tuple[str, str]: - """Get the priority prefix/identifier pair for this concept.""" + """Get the priority prefix/identifier pair for this concept. + + Parameters + ---------- + config : + Configuration defining priority and exclusion for identifiers. + + Returns + ------- + : + A tuple of the priority prefix and identifier for this concept. + """ if config is None: config = DEFAULT_CONFIG identifiers = {k: v for k, v in self.identifiers.items() @@ -175,14 +192,51 @@ def get_curie(self, config: Optional[Config] = None) -> Tuple[str, str]: return sorted(identifiers.items())[0] def get_curie_str(self, config: Optional[Config] = None) -> str: - """Get the priority prefix/identifier as a CURIE string.""" + """Get the priority prefix/identifier as a CURIE string. + + Parameters + ---------- + config : + Configuration defining priority and exclusion for identifiers. + + Returns + ------- + : + A CURIE string for this concept. + """ return ":".join(self.get_curie(config=config)) def get_included_identifiers(self, config: Optional[Config] = None) -> Dict[str, str]: + """Get the identifiers for this concept that are not excluded. + + Parameters + ---------- + config : + Configuration defining priority and exclusion for identifiers. + + Returns + ------- + : + A dict of identifiers for this concept that are not excluded as + defined by the config. + """ config = DEFAULT_CONFIG if config is None else config return {k: v for k, v in self.identifiers.items() if k not in config.prefix_exclusions} def get_key(self, config: Optional[Config] = None): + """Get the key for this concept. + + Parameters + ---------- + config : + Configuration defining priority and exclusion for identifiers. + + Returns + ------- + : + A tuple of the priority prefix and identifier together with the + sorted context of this concept. + """ return ( self.get_curie(config=config), tuple(sorted(self.context.items())), @@ -282,6 +336,18 @@ def refinement_of( @classmethod def from_json(cls, data) -> "Concept": + """Create a Concept from its JSON representation. + + Parameters + ---------- + data : + The JSON representation of the Concept. + + Returns + ------- + : + The Concept object. + """ # Handle Units if isinstance(data, Concept): return data @@ -303,12 +369,33 @@ class Config: SympyExprStr: lambda e: safe_parse_expr(e) } - rate_law: Optional[SympyExprStr] = Field(default=None) - name: Optional[str] = Field(default=None) - display_name: Optional[str] = Field(default=None) + rate_law: Optional[SympyExprStr] = Field( + default=None, description="The rate law for the template." + ) + name: Optional[str] = Field( + default=None, description="The name of the template." + ) + display_name: Optional[str] = Field( + default=None, description="The display name of the template." + ) @classmethod def from_json(cls, data, rate_symbols=None) -> "Template": + """Create a Template from a JSON object + + Parameters + ---------- + data : + The JSON object to create the Template from + rate_symbols : + A mapping of symbols to use for the rate law. If not provided, + the rate law will be parsed without any symbols. + + Returns + ------- + : + A Template object + """ # We make sure to use data such that it's not modified in place, # e.g., we don't use pop or overwrite items, otherwise this function # would have unintended side effects. @@ -451,7 +538,6 @@ def with_context( A mapping of context values to names. Useful if the context values are e.g. curies. Will only be used if ``do_rename`` is True. - Returns ------- : @@ -459,22 +545,50 @@ def with_context( """ raise NotImplementedError("This method can only be called on subclasses") - def get_concepts(self): - """Return the concepts in this template.""" + def get_concepts(self) -> List[Concept]: + """Return the concepts in this template. + + Returns + ------- + : + A list of concepts in this template. + """ + if not hasattr(self, "concept_keys"): + raise NotImplementedError( + "This method can only be called on subclasses of Template" + ) return [getattr(self, k) for k in self.concept_keys] - def get_concepts_by_role(self): - """Return the concepts in this template as a dict keyed by role.""" + def get_concepts_by_role(self) -> Dict[str, Concept]: + """Return the concepts in this template as a dict keyed by role. + + Returns + ------- + : + A dict of concepts in this template keyed by role. + """ return { k: getattr(self, k) for k in self.concept_keys } - def get_concept_names(self): - """Return the concept names in this template.""" + def get_concept_names(self) -> Set[str]: + """Return the concept names in this template. + + Returns + ------- + : + The set of concept names in this template. + """ return {c.name for c in self.get_concepts()} def get_interactors(self) -> List[Concept]: - """Return the interactors in this template.""" + """Return the interactors in this template. + + Returns + ------- + : + A list of interactors in this template. + """ concepts_by_role = self.get_concepts_by_role() if 'controller' in concepts_by_role: controllers = [concepts_by_role['controller']] @@ -487,6 +601,13 @@ def get_interactors(self) -> List[Concept]: return interactors def get_controllers(self): + """Return the controllers in this template. + + Returns + ------- + : + A list of controllers in this template. + """ concepts_by_role = self.get_concepts_by_role() if 'controller' in concepts_by_role: controllers = [concepts_by_role['controller']] @@ -501,6 +622,16 @@ def get_interactor_rate_law(self, independent=False) -> sympy.Expr: This is the part of the rate law that is the product of the interactors but does not include any parameters. + + Parameters + ---------- + independent : + If True, the controllers will assume independent action. + + Returns + ------- + : + The rate law for the interactors in this template. """ rate_law = 1 if not independent: @@ -524,6 +655,8 @@ def get_mass_action_rate_law(self, parameter: str, independent=False) -> sympy.E ---------- parameter : The parameter to use for the mass-action rate law. + independent : + If True, the controllers will assume independent action. Returns ------- @@ -536,7 +669,20 @@ def get_mass_action_rate_law(self, parameter: str, independent=False) -> sympy.E self.get_interactor_rate_law(independent=independent) return rate_law - def get_independent_mass_action_rate_law(self, parameter: str): + def get_independent_mass_action_rate_law(self, parameter: str) -> sympy.Expr: + """Return the mass action rate law for this template with independent + action. + + Parameters + ---------- + parameter : + The parameter to use for the mass-action rate. + + Returns + ------- + : + The mass action rate law for this template with independent action. + """ rate_law = sympy.Symbol(parameter) * \ self.get_interactor_rate_law(independent=True) return rate_law @@ -574,7 +720,13 @@ def with_mass_action_rate_law(self, parameter, independent=False) -> "Template": return template def get_parameter_names(self) -> Set[str]: - """Get the set of parameter names.""" + """Get the set of parameter names. + + Returns + ------- + : + The set of parameter names. + """ if not self.rate_law: return set() return ( @@ -582,15 +734,31 @@ def get_parameter_names(self) -> Set[str]: - self.get_concept_names() ) - def update_parameter_name(self, old_name, new_name): - """Update the name of a parameter in the rate law.""" + def update_parameter_name(self, old_name: str, new_name: str): + """Update the name of a parameter in the rate law. + + Parameters + ---------- + old_name : + The old name of the parameter. + new_name : + The new name of the parameter. + """ if self.rate_law: self.rate_law = self.rate_law.subs(sympy.Symbol(old_name), sympy.Symbol(new_name)) def get_mass_action_symbol(self) -> Optional[sympy.Symbol]: """Get the symbol for the parameter associated with this template's rate law, - assuming it's mass action.""" + assuming it's mass action. + + Returns + ------- + : + The symbol for the parameter associated with this template's rate law, + assuming it's mass action. Returns None if the rate law is not + mass action or if there is no rate law. + """ if not self.rate_law: return None results = sorted(self.get_parameter_names()) @@ -600,17 +768,41 @@ def get_mass_action_symbol(self) -> Optional[sympy.Symbol]: return sympy.Symbol(results[0]) raise ValueError("recovered multiple parameters - not mass action") - def substitute_parameter(self, name, value): - """Substitute a parameter in this template's rate law.""" + def substitute_parameter(self, name: str, value): + """Substitute a parameter in this template's rate law. + + Parameters + ---------- + name : + The name of the parameter to substitute. + value : + The value to substitute. + """ if not self.rate_law: return self.rate_law = SympyExprStr( self.rate_law.args[0].subs(sympy.Symbol(name), value)) def deactivate(self): + """Deactivate this template by setting its rate law to zero.""" if self.rate_law: self.rate_law = SympyExprStr(self.rate_law.args[0] * 0) + def get_key(self, config: Optional[Config] = None) -> Tuple: + """Get the key for this template. + + Parameters + ---------- + config : + Configuration defining priority and exclusion for identifiers. + + Returns + ------- + : + A tuple of the type and concepts in this template. + """ + raise NotImplementedError("This method can only be called on subclasses") + class Provenance(BaseModel): pass @@ -621,10 +813,10 @@ class ControlledConversion(Template): controlled by the controller.""" type: Literal["ControlledConversion"] = Field("ControlledConversion", const=True) - controller: Concept - subject: Concept - outcome: Concept - provenance: List[Provenance] = Field(default_factory=list) + controller: Concept = Field(..., description="The controller of the conversion.") + subject: Concept = Field(..., description="The subject of the conversion.") + outcome: Concept = Field(..., description="The outcome of the conversion.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the conversion.") concept_keys: ClassVar[List[str]] = ["controller", "subject", "outcome"] @@ -655,7 +847,18 @@ def with_context( ) def add_controller(self, controller: Concept) -> "GroupedControlledConversion": - """Add an additional controller.""" + """Add a controller to this template. + + Parameters + ---------- + controller : + The controller to add. + + Returns + ------- + : + A new template with the additional controller. + """ return GroupedControlledConversion( subject=self.subject, outcome=self.outcome, @@ -664,7 +867,18 @@ def add_controller(self, controller: Concept) -> "GroupedControlledConversion": ) def with_controller(self, controller) -> "ControlledConversion": - """Return a copy of this template with the given controller.""" + """Return a copy of this template with the given controller. + + Parameters + ---------- + controller : + The controller to use for the new template. + + Returns + ------- + : + A copy of this template with the given controller. + """ return self.__class__( type=self.type, controller=controller, @@ -685,10 +899,10 @@ def get_key(self, config: Optional[Config] = None): class GroupedControlledConversion(Template): type: Literal["GroupedControlledConversion"] = Field("GroupedControlledConversion", const=True) - controllers: List[Concept] - subject: Concept - outcome: Concept - provenance: List[Provenance] = Field(default_factory=list) + controllers: List[Concept] = Field(..., description="The controllers of the conversion.") + subject: Concept = Field(..., description="The subject of the conversion.") + outcome: Concept = Field(..., description="The outcome of the conversion.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the conversion.") concept_keys: ClassVar[List[str]] = ["controllers", "subject", "outcome"] @@ -721,9 +935,23 @@ def with_context( ) def with_controllers(self, controllers) -> "GroupedControlledConversion": - """Return a copy of this template with the given controllers.""" + """Return a copy of this template with the given controllers. + + Parameters + ---------- + controllers : + The controllers to use for the new template. + + Returns + ------- + : + A copy of this template with the given controllers. + """ if len(self.controllers) != len(controllers): - raise ValueError + raise ValueError( + f"Must replace all controllers. Expecting " + f"{len(self.controllers)} controllers, got {len(controllers)}" + ) return self.__class__( type=self.type, controllers=controllers, @@ -745,7 +973,6 @@ def get_key(self, config: Optional[Config] = None): ) def get_concepts(self): - """Return the concepts in this template.""" return self.controllers + [self.subject, self.outcome] def add_controller(self, controller: Concept) -> "GroupedControlledConversion": @@ -762,9 +989,9 @@ class GroupedControlledProduction(Template): """Specifies a process of production controlled by several controllers""" type: Literal["GroupedControlledProduction"] = Field("GroupedControlledProduction", const=True) - controllers: List[Concept] - outcome: Concept - provenance: List[Provenance] = Field(default_factory=list) + controllers: List[Concept] = Field(..., description="The controllers of the production.") + outcome: Concept = Field(..., description="The outcome of the production.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the production.") concept_keys: ClassVar[List[str]] = ["controllers", "outcome"] @@ -779,11 +1006,21 @@ def get_key(self, config: Optional[Config] = None): ) def get_concepts(self): - """Return a list of the concepts in this template""" return self.controllers + [self.outcome] def add_controller(self, controller: Concept) -> "GroupedControlledProduction": - """Add an additional controller.""" + """Add a controller to this template. + + Parameters + ---------- + controller : + The controller to add. + + Returns + ------- + : + A new template with the additional controller. + """ return GroupedControlledProduction( outcome=self.outcome, provenance=self.provenance, @@ -791,7 +1028,19 @@ def add_controller(self, controller: Concept) -> "GroupedControlledProduction": ) def with_controllers(self, controllers) -> "GroupedControlledProduction": - """Return a copy of this template with the given controllers.""" + """Return a copy of this template with the given controllers. + + Parameters + ---------- + controllers : + The controllers to use for the new template. + + Returns + ------- + : + A copy of this template with the given controllers replacing the + existing controllers. + """ return self.__class__( type=self.type, controllers=controllers, @@ -825,10 +1074,18 @@ def with_context( class ControlledProduction(Template): """Specifies a process of production controlled by one controller""" - type: Literal["ControlledProduction"] = Field("ControlledProduction", const=True) - controller: Concept - outcome: Concept - provenance: List[Provenance] = Field(default_factory=list) + type: Literal["ControlledProduction"] = Field( + "ControlledProduction", const=True + ) + controller: Concept = Field( + ..., description="The controller of the production." + ) + outcome: Concept = Field( + ..., description="The outcome of the production." + ) + provenance: List[Provenance] = Field( + default_factory=list, description="Provenance of the template" + ) concept_keys: ClassVar[List[str]] = ["controller", "outcome"] @@ -840,7 +1097,19 @@ def get_key(self, config: Optional[Config] = None): ) def add_controller(self, controller: Concept) -> "GroupedControlledProduction": - """Add an additional controller.""" + """Add a controller to this template. + + Parameters + ---------- + controller : + The controller to add. + + Returns + ------- + : + A GroupedControlledProduction template with the additional + controller. + """ return GroupedControlledProduction( outcome=self.outcome, provenance=self.provenance, @@ -848,7 +1117,19 @@ def add_controller(self, controller: Concept) -> "GroupedControlledProduction": ) def with_controller(self, controller) -> "ControlledProduction": - """Return a copy of this template with the given controller.""" + """Return a copy of this template with the given controller. + + Parameters + ---------- + controller : + The controller to use for the new template. + + Returns + ------- + : + A copy of this template with the given controller replacing the + existing controller. + """ return self.__class__( type=self.type, controller=controller, @@ -882,9 +1163,9 @@ class NaturalConversion(Template): """Specifies a process of natural conversion from subject to outcome""" type: Literal["NaturalConversion"] = Field("NaturalConversion", const=True) - subject: Concept - outcome: Concept - provenance: List[Provenance] = Field(default_factory=list) + subject: Concept = Field(..., description="The subject of the conversion.") + outcome: Concept = Field(..., description="The outcome of the conversion.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the conversion.") concept_keys: ClassVar[List[str]] = ["subject", "outcome"] @@ -920,8 +1201,8 @@ class NaturalProduction(Template): """A template for the production of a species at a constant rate.""" type: Literal["NaturalProduction"] = Field("NaturalProduction", const=True) - outcome: Concept - provenance: List[Provenance] = Field(default_factory=list) + outcome: Concept = Field(..., description="The outcome of the production.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the production.") concept_keys: ClassVar[List[str]] = ["outcome"] @@ -954,8 +1235,8 @@ class NaturalDegradation(Template): """A template for the degradataion of a species at a proportional rate to its amount.""" type: Literal["NaturalDegradation"] = Field("NaturalDegradation", const=True) - subject: Concept - provenance: List[Provenance] = Field(default_factory=list) + subject: Concept = Field(..., description="The subject of the degradation.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the degradation.") concept_keys: ClassVar[List[str]] = ["subject"] @@ -987,9 +1268,9 @@ class ControlledDegradation(Template): """Specifies a process of degradation controlled by one controller""" type: Literal["ControlledDegradation"] = Field("ControlledDegradation", const=True) - controller: Concept - subject: Concept - provenance: List[Provenance] = Field(default_factory=list) + controller: Concept = Field(..., description="The controller of the degradation.") + subject: Concept = Field(..., description="The subject of the degradation.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the degradation.") concept_keys: ClassVar[List[str]] = ["controller", "subject"] @@ -1001,7 +1282,18 @@ def get_key(self, config: Optional[Config] = None): ) def add_controller(self, controller: Concept) -> "GroupedControlledDegradation": - """Add an additional controller.""" + """Add a controller to this template. + + Parameters + ---------- + controller : + The controller to add. + + Returns + ------- + : + A new template with the additional controller. + """ return GroupedControlledDegradation( subject=self.subject, controllers=[self.controller, controller], @@ -1009,7 +1301,19 @@ def add_controller(self, controller: Concept) -> "GroupedControlledDegradation": ) def with_controller(self, controller) -> "ControlledDegradation": - """Return a copy of this template with the given controller.""" + """Return a copy of this template with the given controller. + + Parameters + ---------- + controller : + The controller to use for the new template. + + Returns + ------- + : + A copy of this template as a ControlledDegradation template + with the given controller replacing the existing controllers. + """ return self.__class__( type=self.type, controller=controller, @@ -1043,9 +1347,9 @@ class GroupedControlledDegradation(Template): """Specifies a process of degradation controlled by several controllers""" type: Literal["GroupedControlledDegradation"] = Field("GroupedControlledDegradation", const=True) - controllers: List[Concept] - subject: Concept - provenance: List[Provenance] = Field(default_factory=list) + controllers: List[Concept] = Field(..., description="The controllers of the degradation.") + subject: Concept = Field(..., description="The subject of the degradation.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the degradation.") concept_keys: ClassVar[List[str]] = ["controllers", "subject"] @@ -1060,11 +1364,21 @@ def get_key(self, config: Optional[Config] = None): ) def get_concepts(self): - """Return a list of the concepts in this template""" return self.controllers + [self.subject] def add_controller(self, controller: Concept) -> "GroupedControlledDegradation": - """Add an additional controller.""" + """Add a controller to this template. + + Parameters + ---------- + controller : + The controller to add. + + Returns + ------- + : + A new template with the additional controller added. + """ return GroupedControlledDegradation( subject=self.subject, provenance=self.provenance, @@ -1072,7 +1386,19 @@ def add_controller(self, controller: Concept) -> "GroupedControlledDegradation": ) def with_controllers(self, controllers) -> "GroupedControlledDegradation": - """Return a copy of this template with the given controllers.""" + """Return a copy of this template with the given controllers. + + Parameters + ---------- + controllers : + The controllers to use for the new template. + + Returns + ------- + : + A copy of this template with the given controllers replacing the + existing controllers. + """ return self.__class__( type=self.type, controllers=controllers, @@ -1102,11 +1428,11 @@ def with_context( class StaticConcept(Template): - """Specifies a standalone Concept.""" + """Specifies a standalone Concept that is not part of a process.""" type: Literal["StaticConcept"] = Field("StaticConcept", const=True) - subject: Concept - provenance: List[Provenance] = Field(default_factory=list) + subject: Concept = Field(..., description="The subject.") + provenance: List[Provenance] = Field(default_factory=list, description="The provenance.") concept_keys: ClassVar[List[str]] = ["subject"] def get_key(self, config: Optional[Config] = None): @@ -1116,7 +1442,6 @@ def get_key(self, config: Optional[Config] = None): ) def get_concepts(self): - """Return a list of the concepts in this template""" return [self.subject] def with_context( @@ -1179,9 +1504,36 @@ def templates_equal(templ: Template, other_templ: Template, with_context: bool, return True -def match_concepts(self_concepts, other_concepts, with_context=True, - config=None, refinement_func=None): - """Return true if there is an exact match between two lists of concepts.""" +def match_concepts( + self_concepts: List[Concept], + other_concepts: List[Concept], + with_context: bool = True, + config: Config = None, + refinement_func: Callable[[str, str], bool] = None, +) -> bool: + """Return true if there is an exact match between two lists of concepts. + + Parameters + ---------- + self_concepts : + The list of concepts to compare to the second list. + other_concepts : + The second list of concepts to compare the first list to. + with_context : + If True, also consider the contexts of the contained Concepts of the + Template when comparing the two lists. Default: True. + config : + Configuration defining priority and exclusion for identifiers. If None, + the default configuration will be used. + refinement_func : + A function to use to check if one concept is a refinement of another. + If None, the default is to check for equality. + + Returns + ------- + : + True if there is an exact match between the two lists of concepts. + """ # First build a bipartite graph of matches G = nx.Graph() for (self_idx, self_concept), (other_idx, other_concept) in \ @@ -1262,7 +1614,26 @@ def context_refinement(refined_context, other_context) -> bool: def has_controller(template: Template, controller: Concept) -> bool: - """Check if the template has a given controller.""" + """Check if the template has a given controller. + + Parameters + ---------- + template : + The template to check. The template must be representing a controlled + process. + controller + The controller to check for + + Returns + ------- + : + True if the template has the given controller + + Raises + ------ + NotImplementedError + If the template is not a controlled process. + """ if isinstance(template, (GroupedControlledProduction, GroupedControlledConversion)): return any( c == controller @@ -1271,4 +1642,6 @@ def has_controller(template: Template, controller: Concept) -> bool: elif isinstance(template, (ControlledProduction, ControlledConversion)): return template.controller == controller else: - raise NotImplementedError + raise NotImplementedError( + f"Template {template.type} is not a controlled process" + ) diff --git a/mira/modeling/__init__.py b/mira/modeling/__init__.py index d08dcb8f9..a3e49db37 100644 --- a/mira/modeling/__init__.py +++ b/mira/modeling/__init__.py @@ -1,3 +1,8 @@ +"""Modeling module for MIRA. + +The top level contains the Model class, toghether with the Variable, +Transition, and ModelParameter classes, used to represent a Model. +""" __all__ = ["Model", "Transition", "Variable", "ModelParameter"] import logging @@ -8,7 +13,23 @@ logger = logging.getLogger(__name__) +# TODO: Consider using dataclasses + + class Transition: + """A transition between two concepts, with a rate law. + + Attributes + ---------- + key : tuple[str] + A tuple of the form (consumed, produced, control, rate) + consumed : tuple[Variable] + The variables consumed by the transition + produced : tuple[Variable] + The variables produced by the transition + control : tuple[Variable] + The variables that control the transition + """ def __init__( self, key, consumed, produced, control, rate, template_type, template: Template, ): @@ -22,6 +43,17 @@ def __init__( class Variable: + """A variable representation of a concept in Model + + Attributes + ---------- + key : tuple[str, ...] + A tuple of strings representing the concept name, grounding, and context + data : dict + A dictionary of data about the variable + concept : Concept + The concept associated with the variable + """ def __init__(self, key, data, concept: Concept): self.key = key self.data = data @@ -29,6 +61,25 @@ def __init__(self, key, data, concept: Concept): class ModelParameter: + """A parameter for a model. + + Attributes + ---------- + key : tuple[str, ...] + A tuple of strings representing the transition key and the parameter type + display_name : str + The display name of the parameter. (optional) + description : str + A description of the parameter. (optional) + value : float + The value of the parameter. (optional) + distribution : str + The distribution of the parameter. (optional) + placeholder : bool + Whether the parameter is a placeholder. (optional) + concept : Concept + The concept associated with the parameter. (optional) + """ def __init__(self, key, display_name=None, description=None, value=None, distribution=None, placeholder=None, concept=None): self.key = key @@ -41,6 +92,15 @@ def __init__(self, key, display_name=None, description=None, value=None, class ModelObservable: + """An observable for a model. + + Attributes + ---------- + observable : Observable + The observable + parameters : tuple[str, ...] + The parameters of the observable + """ def __init__(self, observable, parameters): self.observable = observable self.parameters = parameters @@ -58,7 +118,29 @@ def get_parameter_key(transition_key, action): class Model: - def __init__(self, template_model): + """A model representation generated from a template model. + + Attributes + ---------- + template_model : TemplateModel + The template model used to generate the model + variables : dict[Hashable, Variable] + A dictionary mapping from variable keys to variables + parameters : dict[Hashable, ModelParameter] + A dictionary mapping from parameter keys to parameters + transitions : dict[Hashable, Transition] + A dictionary mapping from transition keys to transitions + observables : dict[Hashable, ModelObservable] + A dictionary mapping from observable keys to observables + """ + def __init__(self, template_model: TemplateModel): + """ + + Parameters + ---------- + template_model : + A template model to generate a model from + """ self.template_model = template_model self.variables: Dict[Hashable, Variable] = {} self.parameters: Dict[Hashable, ModelParameter] = {} @@ -68,9 +150,9 @@ def __init__(self, template_model): def assemble_variable( self, concept: Concept, initials: Optional[Mapping[str, Initial]] = None, - ): - """Assemble a variable from a concept and optional - dictionary of initial values. + ) -> Variable: + """Assemble a variable from a concept and optional dictionary of + initial values. Parameters ---------- @@ -82,7 +164,8 @@ def assemble_variable( Returns ------- - A variable object, representing a concept and its initial value + : + A variable object, representing a concept and its initial value """ grounding_key = sorted( ("identity", f"{k}:{v}") @@ -114,6 +197,20 @@ def assemble_variable( return var def assemble_parameter(self, template: Template, tkey) -> ModelParameter: + """Assemble a parameter from a template and a transition key. + + Parameters + ---------- + template : + The template to assemble a parameter from + tkey : tuple[str, ...] + The transition key to assemble a parameter from + + Returns + ------- + : + A model parameter + """ rate_parameters = sorted( self.template_model.get_parameters_from_rate_law(template.rate_law)) @@ -140,6 +237,7 @@ def assemble_parameter(self, template: Template, tkey) -> ModelParameter: placeholder=True)) def make_model(self): + """Initialize the model""" for name, observable in self.template_model.observables.items(): params = sorted( observable.get_parameter_names(self.template_model.parameters)) diff --git a/mira/modeling/acsets/stockflow.py b/mira/modeling/acsets/stockflow.py index 6ce2c38c3..53a67a176 100644 --- a/mira/modeling/acsets/stockflow.py +++ b/mira/modeling/acsets/stockflow.py @@ -1,15 +1,29 @@ +"""This module implements generation into stock and flow models which are +defined through a set of stocks, flows, links, and the input and output +connections between flows. +""" + +__all__ = ["ACSetsStockFlowModel", "template_model_to_stockflow_ascet_json"] from mira.modeling import Model from mira.metamodel import * class ACSetsStockFlowModel: + """A class representing a stock and flow model.""" def __init__(self, model: Model): + """Instantiate a stock and flow model from a generic transition model. + + Parameters + ---------- + model : + The pre-compiled transition model. + """ self.properties = {} self.stocks = [] self.flows = [] self.links = [] - self.model_name = 'Model' + self.model_name = "Model" for idx, flow in enumerate(model.transitions.values()): fid = flow.template.name @@ -18,7 +32,9 @@ def __init__(self, model: Model): input = flow.consumed[0].key output = flow.produced[0].key - rate_law_str = str(flow.template.rate_law) if flow.template.rate_law else None + rate_law_str = ( + str(flow.template.rate_law) if flow.template.rate_law else None + ) if rate_law_str: for param_key, param_obj in model.parameters.items(): if param_obj.placeholder: @@ -26,19 +42,25 @@ def __init__(self, model: Model): index = rate_law_str.find(param_key) if index < 0: continue - rate_law_str = rate_law_str[:index] + 'p.' + rate_law_str[index:] + rate_law_str = ( + rate_law_str[:index] + "p." + rate_law_str[index:] + ) for var_obj in model.variables.values(): index = rate_law_str.find(var_obj.concept.display_name) if index < 0: continue - rate_law_str = rate_law_str[:index] + 'u.' + rate_law_str[index:] - - flow_dict = {'_id': fid, - 'u': input, - 'd': output, - 'fname': fname, - 'ϕf': rate_law_str} + rate_law_str = ( + rate_law_str[:index] + "u." + rate_law_str[index:] + ) + + flow_dict = { + "_id": fid, + "u": input, + "d": output, + "fname": fname, + "ϕf": rate_law_str, + } self.flows.append(flow_dict) @@ -48,37 +70,57 @@ def __init__(self, model: Model): display_name = var.concept.display_name or name stocks_dict = { - '_id': name, - 'sname': display_name, + "_id": name, + "sname": display_name, } self.stocks.append(stocks_dict) - # Declare 's' and 't' field of a link before assignment, this is because if a stock is found to be - # a target for a flow before it is a source, then the 't' field of the link associated with a stock + # Declare 's' and 't' field of a link before assignment, + # this is because if a stock is found to be + # a target for a flow before it is a source, then the 't' + # field of the link associated with a stock # will be displayed first - links_dict = {'_id': name} - links_dict['s'] = None - links_dict['t'] = None + links_dict = {"_id": name} + links_dict["s"] = None + links_dict["t"] = None for flow in model.transitions.values(): if flow.consumed[0].concept.name == name: - links_dict['s'] = flow.template.name + links_dict["s"] = flow.template.name if flow.produced[0].concept.name == name: - links_dict['t'] = flow.template.name + links_dict["t"] = flow.template.name - if not links_dict.get('s') and links_dict.get('t'): - links_dict['s'] = links_dict.get('t') - elif links_dict.get('s') and not links_dict.get('t'): - links_dict['t'] = links_dict.get('s') + if not links_dict.get("s") and links_dict.get("t"): + links_dict["s"] = links_dict.get("t") + elif links_dict.get("s") and not links_dict.get("t"): + links_dict["t"] = links_dict.get("s") self.links.append(links_dict) def to_json(self): - return { - 'Flow': self.flows, - 'Stock': self.stocks, - 'Link': self.links - } + """ + Return a JSON dict structure of the Petri net model. + + Returns + ------- + : JSON + The JSON dict structure of the stock and flow model. + """ + return {"Flow": self.flows, "Stock": self.stocks, "Link": self.links} def template_model_to_stockflow_ascet_json(tm: TemplateModel): + """ + Convert a TemplateModel into stock flow JSON and return the converted + model. + + Parameters + ---------- + tm : + The TemplateModel to be converted. + + Returns + ------- + : JSON + The JSON structure representing the stock flow model. + """ return ACSetsStockFlowModel(Model(tm)).to_json() diff --git a/mira/modeling/amr/ops.py b/mira/modeling/amr/ops.py index 8a6c8577c..d577ffd83 100644 --- a/mira/modeling/amr/ops.py +++ b/mira/modeling/amr/ops.py @@ -1,4 +1,15 @@ +"""This module contains functions for editing ASKEM Model Representation +models +""" +# NOTE: the docstrings of the wrapped functions reflect the expected +# input/output of the functions with the wrapper applied, i.e. the argument +# ``model`` would be a template model object for the function but with the +# wrapper applied, ``model`` would be an AMR JSON object, so the docstring +# would reflect that + import copy +from functools import wraps + import sympy from mira.metamodel import SympyExprStr, Unit import mira.metamodel.ops as tmops @@ -6,14 +17,16 @@ from .petrinet import template_model_to_petrinet_json from mira.metamodel.io import mathml_to_expression from mira.metamodel.template_model import Parameter, Distribution, Observable, \ - Initial, Concept + Initial, Concept, TemplateModel from mira.metamodel.templates import NaturalConversion, NaturalProduction, \ NaturalDegradation, StaticConcept from typing import Mapping def amr_to_mira(func): - def wrapper(amr, *args, **kwargs): + @wraps(func) + def wrapper(model, *args, **kwargs): + amr = model tm = template_model_from_amr_json(amr) result = func(tm, *args, **kwargs) amr = template_model_to_petrinet_json(result) @@ -24,8 +37,25 @@ def wrapper(amr, *args, **kwargs): # Edit ID / label / name of State, Transition, Observable, Parameter, Initial @amr_to_mira -def replace_state_id(tm, old_id, new_id): - """Replace the ID of a state.""" +def replace_state_id(model, old_id: str, new_id: str): + """Replace the ID of a state. + + Parameters + ---------- + model : JSON + The model as an AMR JSON + old_id : + The ID of the state to replace + new_id : + The new ID to replace the old ID with + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model concepts_name_map = tm.get_concepts_name_map() if old_id not in concepts_name_map: raise ValueError(f"State with ID {old_id} not found in model.") @@ -50,8 +80,25 @@ def replace_state_id(tm, old_id, new_id): @amr_to_mira -def replace_transition_id(tm, old_id, new_id): - """Replace the ID of a transition.""" +def replace_transition_id(model, old_id, new_id): + """Replace the ID of a transition. + + Parameters + ---------- + model : JSON + The model as an AMR JSON + old_id : + The ID of the transition to replace + new_id : + The new ID to replace the old ID with + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model for template in tm.templates: if template.name == old_id: template.name = new_id @@ -59,8 +106,27 @@ def replace_transition_id(tm, old_id, new_id): @amr_to_mira -def replace_observable_id(tm, old_id, new_id, name=None): - """Replace the ID and display name (optional) of an observable""" +def replace_observable_id(model, old_id: str, new_id: str, name: str = None): + """Replace the ID and display name (optional) of an observable. + + Parameters + ---------- + model : JSON + The model as an AMR JSON + old_id : + The ID of the observable to replace + new_id : + The new ID to replace the old ID with + name : + The new display name to replace the old display name with (optional) + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model for obs, observable in copy.deepcopy(tm.observables).items(): if obs == old_id: observable.name = new_id @@ -71,8 +137,23 @@ def replace_observable_id(tm, old_id, new_id, name=None): @amr_to_mira -def remove_observable(tm, removed_id): - """Remove an observable from the template model""" +def remove_observable(model, removed_id: str): + """Remove an observable from the template model + + Parameters + ---------- + model : JSON + The model as an AMR JSON + removed_id : + The ID of the observable to remove + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model for obs, observable in copy.deepcopy(tm.observables).items(): if obs == removed_id: tm.observables.pop(obs) @@ -80,11 +161,27 @@ def remove_observable(tm, removed_id): @amr_to_mira -def remove_parameter(tm, removed_id, replacement_value=None): +def remove_parameter(model, removed_id: str, replacement_value=None): """ Substitute every instance of the parameter with the given replacement_value. If replacement_value is none, substitute the parameter with 0. + + Parameters + ---------- + model : JSON + The model as an AMR JSON + removed_id : + The ID of the parameter to remove + replacement_value : + The value to replace the parameter with (optional) + + Returns + ------- + : JSON + The updated model as an AMR JSON """ + assert isinstance(model, TemplateModel) + tm = model if replacement_value: tm.substitute_parameter(removed_id, replacement_value) else: @@ -99,8 +196,27 @@ def remove_parameter(tm, removed_id, replacement_value=None): @amr_to_mira -def add_observable(tm, new_id, new_name, new_expression): - """Add a new observable object to the template model""" +def add_observable(model, new_id: str, new_name: str, new_expression: str): + """Add a new observable object to the template model + + Parameters + ---------- + model : JSON + The model as an AMR JSON + new_id : + The ID of the new observable to add + new_name : + The display name of the new observable to add + new_expression : + The expression of the new observable to add as a MathML XML string + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model # Note that if an observable already exists with the given # key, it will be replaced rate_law_sympy = mathml_to_expression(new_expression) @@ -111,8 +227,25 @@ def add_observable(tm, new_id, new_name, new_expression): @amr_to_mira -def replace_parameter_id(tm, old_id, new_id): - """Replace the ID of a parameter""" +def replace_parameter_id(model, old_id: str, new_id: str): + """Replace the ID of a parameter + + Parameters + ---------- + model : JSON + The model as an AMR JSON + old_id : + The ID of the parameter to replace + new_id : + The new ID to replace the old ID with + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model if old_id not in tm.parameters: raise ValueError(f"Parameter with ID {old_id} not found in model.") for template in tm.templates: @@ -138,20 +271,67 @@ def replace_parameter_id(tm, old_id, new_id): @amr_to_mira -def add_parameter(tm, parameter_id: str, - name: str = None, - description: str = None, - value: float = None, - distribution=None, - units_mathml: str = None): - """Add a new parameter to the template model""" - tm.add_parameter(parameter_id, name, description, value, distribution, units_mathml) +def add_parameter( + model, + parameter_id: str, + name: str = None, + description: str = None, + value: float = None, + distribution: Distribution = None, + units_mathml: str = None +): + """Add a new parameter to the template model + + Parameters + ---------- + model : JSON + The model as an AMR JSON + parameter_id : + The ID of the new parameter to add + name : + The display name of the new parameter (optional) + description : + The description of the new parameter (optional) + value : + The value of the new parameter (optional) + distribution : + The distribution of the new parameter (optional) + units_mathml : + The units of the new parameter as a MathML XML string (optional) + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model + tm.add_parameter( + parameter_id, name, description, value, distribution, units_mathml + ) return tm @amr_to_mira -def replace_initial_id(tm, old_id, new_id): - """Replace the ID of an initial.""" +def replace_initial_id(model, old_id: str, new_id: str): + """Replace the ID of an initial. + + Parameters + ---------- + model : JSON + The model as an AMR JSON + old_id : + The ID of the initial to replace + new_id : + The new ID to replace the old ID with + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model tm.initials = { (new_id if k == old_id else k): v for k, v in tm.initials.items() } @@ -160,8 +340,23 @@ def replace_initial_id(tm, old_id, new_id): # Remove state @amr_to_mira -def remove_state(tm, state_id): - """Remove a state from the template model""" +def remove_state(model, state_id: str): + """Remove a state from the template model + + Parameters + ---------- + model : JSON + The model as an AMR JSON + state_id : + The ID of the state to remove + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model new_templates = [] for template in tm.templates: to_remove = False @@ -179,10 +374,38 @@ def remove_state(tm, state_id): @amr_to_mira -def add_state(tm, state_id: str, name: str = None, - units_mathml: str = None, grounding: Mapping[str, str] = None, - context: Mapping[str, str] = None): - """Add a new state to the template model""" +def add_state( + model, + state_id: str, + name: str = None, + units_mathml: str = None, + grounding: Mapping[str, str] = None, + context: Mapping[str, str] = None +): + """Add a new state to the template model + + Parameters + ---------- + model : JSON + The model as an AMR JSON + state_id : + The ID of the new state to add + name : + The display name of the new state (optional) + units_mathml : + The units of the new state as a MathML XML string (optional) + grounding : + The grounding of the new state (optional) + context : + The context of the new state (optional) + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model if units_mathml: units = Unit(expression=SympyExprStr(mathml_to_expression(units_mathml))) else: @@ -200,37 +423,61 @@ def add_state(tm, state_id: str, name: str = None, @amr_to_mira -def remove_transition(tm, transition_id): - """Remove a transition object from the template model""" +def remove_transition(model, transition_id: str): + """Remove a transition object from the template model + + Parameters + ---------- + model : JSON + The model as an AMR JSON + transition_id : + The ID of the transition to remove + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model tm.templates = [t for t in tm.templates if t.name != transition_id] return tm @amr_to_mira -def add_transition(tm, new_transition_id, src_id=None, tgt_id=None, - rate_law_mathml=None, params_dict: Mapping = None): - """Add a new transition to the template model +def add_transition( + model, + new_transition_id: str, + src_id: str = None, + tgt_id: str = None, + rate_law_mathml: str = None, + params_dict: Mapping = None +): + """Add a new transition to a model Parameters ---------- - tm: - The template model + model : JSON + The model as an AMR JSON new_transition_id: The ID of the new transition to add - src_id: + src_id : The ID of the subject of the newly created transition (default None) - tgt_id: + tgt_id : The ID of the outcome of the newly created transition (default None) - rate_law_math_ml: + rate_law_mathml : The rate law associated with the newly created transition - params_dict: - A mapping of parameter attributes to their respective values if the user - decides to explicitly create parameters + params_dict : + A mapping of parameter attributes to their respective values if the + user decides to explicitly create parameters + Returns ------- - : - The updated template model object + : JSON + The updated model as an AMR JSON """ + assert isinstance(model, TemplateModel) + tm = model if src_id is None and tgt_id is None: ValueError("You must pass in at least one of source and target id") if src_id not in tm.get_concepts_name_map() and tgt_id not in tm.get_concepts_name_map(): @@ -250,10 +497,28 @@ def add_transition(tm, new_transition_id, src_id=None, tgt_id=None, @amr_to_mira -def replace_rate_law_sympy(tm, transition_id, new_rate_law: sympy.Expr): - """Replace the rate law of transition. The new rate law passed in will be a sympy.Expr object""" +def replace_rate_law_sympy(model, transition_id: str, new_rate_law: sympy.Expr): + """Replace the rate law of transition. The new rate law passed in will be a sympy.Expr object + + Parameters + ---------- + model : + The model as an AMR JSON + transition_id : + The ID of the transition whose rate law is to be replaced, this is + typically the name of the transition + new_rate_law : + The new rate law to replace the existing rate law with + + Returns + ------- + : + The updated model as an AMR JSON + """ # NOTE: this assumes that a sympy expression object is given # though it might make sense to take a string instead + assert isinstance(model, TemplateModel) + tm = model for template in tm.templates: if template.name == transition_id: template.rate_law = SympyExprStr(new_rate_law) @@ -262,16 +527,54 @@ def replace_rate_law_sympy(tm, transition_id, new_rate_law: sympy.Expr): # This function isn't wrapped because it calls a wrapped function and just # passes the AMR through -def replace_rate_law_mathml(amr, transition_id, new_rate_law): - """Replace the rate law of a transition. The new rate law passed in will be a MathML str object""" +def replace_rate_law_mathml(model, transition_id: str, new_rate_law: str): + """Replace the rate law of a transition. + + Parameters + ---------- + model : JSON + The model as an AMR JSON + transition_id : + The ID of the transition whose rate law is to be replaced, this is + typically the name of the transition + new_rate_law : + The new rate law to replace the existing rate law with as a MathML + XML string + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ new_rate_law_sympy = mathml_to_expression(new_rate_law) - return replace_rate_law_sympy(amr, transition_id, new_rate_law_sympy) + return replace_rate_law_sympy(model, transition_id, new_rate_law_sympy) @amr_to_mira -def replace_observable_expression_sympy(tm, obs_id, - new_expression_sympy: sympy.Expr): - """Replace the expression of an observable. The new rate law passed in will be a sympy.Expr object""" +def replace_observable_expression_sympy( + model, + obs_id: str, + new_expression_sympy: sympy.Expr +): + """Replace the expression of an observable + + Parameters + ---------- + model : JSON + The model as an AMR JSON + obs_id : + The ID of the observable to replace the expression of + new_expression_sympy : + The new expression to replace the existing expression with as a + sympy.Expr object + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model for obs, observable in tm.observables.items(): if obs == obs_id: observable.expression = SympyExprStr(new_expression_sympy) @@ -279,9 +582,30 @@ def replace_observable_expression_sympy(tm, obs_id, @amr_to_mira -def replace_initial_expression_sympy(tm, initial_id, - new_expression_sympy: sympy.Expr): - """Replace the expression of an initial. The new rate law passed in will be a sympy.Expr object""" +def replace_initial_expression_sympy( + model, + initial_id: str, + new_expression_sympy: sympy.Expr +): + """Replace the expression of an initial. + + Parameters + ---------- + model : JSON + The model as an AMR JSON + initial_id : + The ID of the initial to replace the expression of + new_expression_sympy : + The new expression to replace the existing expression with as a + sympy.Expr object + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ + assert isinstance(model, TemplateModel) + tm = model for init, initial in tm.initials.items(): if init == initial_id: initial.expression = SympyExprStr(new_expression_sympy) @@ -290,8 +614,28 @@ def replace_initial_expression_sympy(tm, initial_id, # This function isn't wrapped because it calls a wrapped function and just # passes the AMR through -def replace_observable_expression_mathml(amr, obs_id, new_expression_mathml): - """Replace the expression of an observable. The new rate law passed in will be MathML str object""" +def replace_observable_expression_mathml( + amr, + obs_id: str, + new_expression_mathml: str +): + """Replace the expression of an observable. + + Parameters + ---------- + amr : JSON + The model as an AMR JSON + obs_id : + The ID of the observable to replace the expression of + new_expression_mathml : + The new expression to replace the existing expression with as a + MathML XML string + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ new_expression_sympy = mathml_to_expression(new_expression_mathml) return replace_observable_expression_sympy(amr, obs_id, new_expression_sympy) @@ -299,8 +643,28 @@ def replace_observable_expression_mathml(amr, obs_id, new_expression_mathml): # This function isn't wrapped because it calls a wrapped function and just # passes the AMR through -def replace_initial_expression_mathml(amr, initial_id, new_expression_mathml): - """Replace the expression of an initial. The new rate law passed in will be a MathML str object""" +def replace_initial_expression_mathml( + amr, + initial_id: str, + new_expression_mathml: str +): + """Replace the expression of an initial. + + Parameters + ---------- + amr : JSON + The model as an AMR JSON + initial_id : + The ID of the initial to replace the expression of + new_expression_mathml : + The new expression to replace the existing expression with as a + MathML XML string + + Returns + ------- + : JSON + The updated model as an AMR JSON + """ new_expression_sympy = mathml_to_expression(new_expression_mathml) return replace_initial_expression_sympy(amr, initial_id, new_expression_sympy) @@ -324,3 +688,21 @@ def aggregate_parameters(*args, **kwargs): @amr_to_mira def counts_to_dimensionless(*args, **kwargs): return tmops.counts_to_dimensionless(*args, **kwargs) + + +def _fix_docstring(docstr: str) -> str: + # Replace template_model and 'template model' with 'model' + return docstr.replace("template_model", "model").replace( + "template model", "AMR model JSON" + ).replace("A AMR", "An AMR").replace("a AMR", "an AMR") + + +# Copy the docstrings of the wrapped functions +# fixme: return type is not copied over currently +stratify.__doc__ = _fix_docstring(tmops.stratify.__doc__) +simplify_rate_laws.__doc__ = _fix_docstring(tmops.simplify_rate_laws.__doc__) +aggregate_parameters.__doc__ = _fix_docstring( + tmops.aggregate_parameters.__doc__) +counts_to_dimensionless.__doc__ = _fix_docstring( + tmops.counts_to_dimensionless.__doc__.replace("tm", "model") +) diff --git a/mira/modeling/amr/petrinet.py b/mira/modeling/amr/petrinet.py index 0d8cfdcbb..5fa88025b 100644 --- a/mira/modeling/amr/petrinet.py +++ b/mira/modeling/amr/petrinet.py @@ -202,8 +202,32 @@ def __init__(self, model: Model): add_metadata_annotations(self.metadata, model) - def to_json(self, name=None, description=None, model_version=None): - """Return a JSON dict structure of the Petri net model.""" + def to_json( + self, + name: str = None, + description: str = None, + model_version: str = None + ): + """Return a JSON dict structure of the Petri net model. + + Parameters + ---------- + name : + The name of the model. Defaults to the name of the original + template model that produced the input Model instance or, if not + available, 'Model'. + description : + A description of the model. Defaults to the description of the + original template model that produced the input Model instance or, + if not available, the name of the model. + model_version : + The version of the model. Defaults to '0.1'. + + Returns + ------- + : JSON + A JSON dict representing the Petri net model. + """ return { 'header': { 'name': name or self.model_name, @@ -228,6 +252,26 @@ def to_json(self, name=None, description=None, model_version=None): } def to_pydantic(self, name=None, description=None, model_version=None) -> "ModelSpecification": + """Return a Pydantic model representation of the Petri net model. + + Parameters + ---------- + name : + The name of the model. Defaults to the name of the original + template model that produced the input Model instance or, if not + available, 'Model'. + description : + A description of the model. Defaults to the description of the + original template model that produced the input Model instance or, + if not available, the name of the model. + model_version : + The version of the model. Defaults to '0.1'. + + Returns + ------- + : + A Pydantic model representation of the Petri net model. + """ return ModelSpecification( header=Header( name=name or self.model_name, @@ -251,8 +295,19 @@ def to_pydantic(self, name=None, description=None, model_version=None) -> "Model metadata=self.metadata, ) - def to_json_str(self, **kwargs): - """Return a JSON string representation of the Petri net model.""" + def to_json_str(self, **kwargs) -> str: + """Return a JSON string representation of the Petri net model. + + Parameters + ---------- + kwargs : + Additional keyword arguments to pass to :func:`json.dumps`. + + Returns + ------- + : + A JSON string representation of the Petri net model. + """ return json.dumps(self.to_json(), **kwargs) def to_json_file(self, fname, name=None, description=None, @@ -390,6 +445,7 @@ class Header(BaseModel): class ModelSpecification(BaseModel): + """A Pydantic model corresponding to the PetriNet JSON schema.""" header: Header properties: Optional[Dict] model: PetriModel diff --git a/mira/modeling/amr/regnet.py b/mira/modeling/amr/regnet.py index fe4ae8da1..a57b3ff02 100644 --- a/mira/modeling/amr/regnet.py +++ b/mira/modeling/amr/regnet.py @@ -148,8 +148,32 @@ def __init__(self, model: Model): add_metadata_annotations(self.metadata, model) - def to_json(self, name=None, description=None, model_version=None): - """Return a JSON dict structure of the Petri net model.""" + def to_json( + self, + name: str = None, + description: str = None, + model_version: str = None + ): + """Return a JSON dict structure of the Petri net model. + + Parameters + ---------- + name : + The name of the model. Defaults to the model name of the original + template model of the input Model instance, or "Model" if no name + is available. + description : + The description of the model. Defaults to the description of the + original template model of the input Model instance, or the model + name if no description is available. + model_version : + The version of the model. Defaults to 0.1 + + Returns + ------- + : JSON + A JSON representation of the Petri net model. + """ return { 'header': { 'name': name or self.model_name, @@ -166,7 +190,32 @@ def to_json(self, name=None, description=None, model_version=None): 'metadata': self.metadata, } - def to_pydantic(self, name=None, description=None, model_version=None) -> "ModelSpecification": + def to_pydantic( + self, + name: str = None, + description: str = None, + model_version: str = None + ) -> "ModelSpecification": + """Return a Pydantic model specification of the Petri net model. + + Parameters + ---------- + name : + The name of the model. Defaults to the model name of the original + template model of the input Model instance, or "Model" if no name + is available. + description : + The description of the model. Defaults to the description of the + original template model of the input Model instance, or the model + name if no description is available. + model_version : + The version of the model. Defaults to 0.1 + + Returns + ------- + : + A Pydantic model specification of the Petri net model. + """ return ModelSpecification( header=Header( name=name or self.model_name, @@ -183,12 +232,47 @@ def to_pydantic(self, name=None, description=None, model_version=None) -> "Model ) def to_json_str(self, **kwargs): - """Return a JSON string representation of the Petri net model.""" + """Return a JSON string representation of the Petri net model. + + Parameters + ---------- + **kwargs : + Keyword arguments to be passed to json.dumps + + Returns + ------- + : + A JSON string representation of the Petri net model. + """ return json.dumps(self.to_json(), **kwargs) - def to_json_file(self, fname, name=None, description=None, - model_version=None, **kwargs): - """Write the Petri net model to a JSON file.""" + def to_json_file( + self, + fname: str, + name: str = None, + description: str = None, + model_version: str = None, + **kwargs + ): + """Write the Petri net model to a JSON file. + + Parameters + ---------- + fname : + The file name to write to. + name : + The name of the model. Defaults to the model name of the original + template model of the input Model instance, or "Model" if no name + is available. + description : + The description of the model. Defaults to the description of the + original template model of the input Model instance, or the model + name if no description is available. + model_version : + The version of the model. Defaults to 0.1 + **kwargs : + Keyword arguments to be passed to json.dump + """ js = self.to_json(name=name, description=description, model_version=model_version) with open(fname, 'w') as fh: @@ -257,6 +341,7 @@ class Header(BaseModel): class ModelSpecification(BaseModel): + """A Pydantic model specification of the Petri net model.""" header: Header properties: Optional[Dict] model: RegNetModel diff --git a/mira/modeling/amr/stockflow.py b/mira/modeling/amr/stockflow.py index 83ddfe5ab..060f8643a 100644 --- a/mira/modeling/amr/stockflow.py +++ b/mira/modeling/amr/stockflow.py @@ -230,6 +230,7 @@ def template_model_to_stockflow_json(tm: TemplateModel): Returns ------- - A JSON dict representing the Stock and Flow model. + : JSON + A JSON dict representing the Stock and Flow model. """ return AMRStockFlowModel(Model(tm)).to_json() diff --git a/mira/modeling/bilayer.py b/mira/modeling/bilayer.py index 63c95343f..c02738739 100644 --- a/mira/modeling/bilayer.py +++ b/mira/modeling/bilayer.py @@ -8,12 +8,29 @@ class BilayerModel: """Class to generate a bilayer model from a transition model.""" def __init__(self, model: Model): + """ + Parameters + ---------- + model : + A MIRA transition model. + """ self.model = model self.bilayer = self.make_bilayer(self.model) @staticmethod - def make_bilayer(model): - """Generate a bilayer structure and return as a JSON dict.""" + def make_bilayer(model: Model): + """Generate a bilayer structure and return as a JSON dict. + + Parameters + ---------- + model : + A MIRA transition model. + + Returns + ------- + JSON + A JSON dict representing the bilayer structure. + """ vars = {variable.key: idx + 1 for idx, variable in enumerate(model.variables.values())} boxes = [] @@ -34,8 +51,19 @@ def make_bilayer(model): wa.append({'influx': box_idx + 1, 'infusion': vars[produced.key]}) - def tanvar_key(var_key): - """Generate the key for the derivative.""" + def tanvar_key(var_key) -> str: + """Generate the key for the derivative. + + Parameters + ---------- + var_key : str | tuple[str] + The key of the variable. + + Returns + ------- + : + The key of the derivative. + """ # If the variable key is a simple string, we just add ' following # the example bilayer. If it's a tuple, we add the "derivative" if isinstance(var_key, str): @@ -49,7 +77,13 @@ def tanvar_key(var_key): return {'Wa': wa, 'Win': win, 'Box': boxes, 'Qin': qin, 'Qout': qout, 'Wn': wn} - def save_bilayer(self, fname): - """Save the generated bilayer into a JSON file.""" + def save_bilayer(self, fname: str): + """Save the generated bilayer into a JSON file. + + Parameters + ---------- + fname : + The file path to save the bilayer to. + """ with open(fname, 'w') as fh: json.dump(self.bilayer, fh, indent=1) diff --git a/mira/sources/acsets/decapodes/deca_expr.py b/mira/sources/acsets/decapodes/deca_expr.py index 711805b25..ce071989f 100644 --- a/mira/sources/acsets/decapodes/deca_expr.py +++ b/mira/sources/acsets/decapodes/deca_expr.py @@ -495,17 +495,17 @@ def replace_variable(replacement: Variable, def process_decaexpr(decaexpr_json) -> Decapode: - """Process a DecaExpr JSON into a Decapode object + """Process a DecaExpr JSON into a Decapode object. Parameters ---------- - decaexpr_json : dict - The DecaExpr JSON of a model + decaexpr_json : JSON + The DecaExpr JSON of a model. Returns ------- - Decapode - The corresponding MIRA Decapode object + : + The corresponding MIRA Decapode object. """ decaexpr_json_model = decaexpr_json["model"] variables = get_variables_mapping_decaexpr(decaexpr_json_model) diff --git a/mira/sources/acsets/decapodes/decapodes.py b/mira/sources/acsets/decapodes/decapodes.py index 60a5dcb79..c259dcfad 100644 --- a/mira/sources/acsets/decapodes/decapodes.py +++ b/mira/sources/acsets/decapodes/decapodes.py @@ -1,56 +1,68 @@ -__all__ = ['process_decapode'] +__all__ = ["process_decapode"] from collections import defaultdict from mira.metamodel.decapodes import * -def process_decapode(decapode_json): +def process_decapode(decapode_json) -> Decapode: + """Process a Decapode compute graph JSON structure into a Decapode object. + + Parameters + ---------- + decapode_json : JSON + The Decapode compute graph JSON of a model. + + Returns + ------- + : + The corresponding MIRA Decapode object. + """ data = decapode_json variables = { - var['_id']: Variable( - id=var['_id'], - type=var['type'], - name=var['name'] - ) for var in data['Var'] + var["_id"]: Variable(id=var["_id"], type=var["type"], name=var["name"]) + for var in data["Var"] } op1s = { - op['_id']: Op1( - id=op['_id'], - src=variables[op['src']], - tgt=variables[op['tgt']], - function_str=op['op1'] - ) for op in data['Op1'] + op["_id"]: Op1( + id=op["_id"], + src=variables[op["src"]], + tgt=variables[op["tgt"]], + function_str=op["op1"], + ) + for op in data["Op1"] } op2s = { - op['_id']: Op2( - id=op['_id'], - proj1=variables[op['proj1']], - proj2=variables[op['proj2']], - res=variables[op['res']], - function_str=op['op2'] - ) for op in data['Op2'] + op["_id"]: Op2( + id=op["_id"], + proj1=variables[op["proj1"]], + proj2=variables[op["proj2"]], + res=variables[op["res"]], + function_str=op["op2"], + ) + for op in data["Op2"] } summands_by_summation = defaultdict(list) - for summand_json in data['Summand']: - var = variables[summand_json['summand']] - summands_by_summation[summand_json['summation']].append(var) + for summand_json in data["Summand"]: + var = variables[summand_json["summand"]] + summands_by_summation[summand_json["summation"]].append(var) summations = { - summation['_id']: Summation( - id=summation['_id'], - summands=summands_by_summation[summation['_id']], - sum=variables[summation['sum']] - ) for summation in data['Σ'] + summation["_id"]: Summation( + id=summation["_id"], + summands=summands_by_summation[summation["_id"]], + sum=variables[summation["sum"]], + ) + for summation in data["Σ"] } tangent_variables = { - tangent_var['_id']: TangentVariable( - id=tangent_var['_id'], - incl_var=variables[tangent_var['incl']] - ) for tangent_var in data['TVar'] + tangent_var["_id"]: TangentVariable( + id=tangent_var["_id"], incl_var=variables[tangent_var["incl"]] + ) + for tangent_var in data["TVar"] } return Decapode( @@ -58,5 +70,5 @@ def process_decapode(decapode_json): op1s=op1s, op2s=op2s, summations=summations, - tangent_variables=tangent_variables + tangent_variables=tangent_variables, ) diff --git a/mira/sources/acsets/stockflow.py b/mira/sources/acsets/stockflow.py index a6f84ebb2..b86ae2450 100644 --- a/mira/sources/acsets/stockflow.py +++ b/mira/sources/acsets/stockflow.py @@ -2,39 +2,63 @@ import sympy import requests +from mira.metamodel import ( + TemplateModel, + safe_parse_expr, + StaticConcept, + UNIT_SYMBOLS, + Unit, + Concept, + Distribution, + Parameter, +) from mira.sources.util import get_sympy, transition_to_templates -from mira.modeling.acsets.stockflow import * +from mira.modeling.acsets.stockflow import template_model_to_stockflow_ascet_json def template_model_from_stockflow_ascet_json(model_json) -> TemplateModel: - stocks = model_json.get('Stock', []) + """ + Returns a TemplateModel by processing a Stock and flow JSON dict. + + Parameters + ---------- + model_json : JSON + The stock and flow JSON structure. + + Returns + ------- + : + A TemplateModel extracted from the Stock and flow model. + """ + stocks = model_json.get("Stock", []) # process stocks/states concepts = {} all_stocks = set() for stock in stocks: concept_stock = stock_to_concept(stock) - concepts[stock['_id']] = concept_stock - all_stocks.add(stock['_id']) + concepts[stock["_id"]] = concept_stock + all_stocks.add(stock["_id"]) symbols, mira_parameters = {}, {} # Store stocks as parameters for stock_id, concept_item in concepts.items(): - symbols[concept_item.display_name] = \ - sympy.Symbol(concept_item.display_name) + symbols[concept_item.display_name] = sympy.Symbol( + concept_item.display_name + ) used_stocks = set() - flows = model_json['Flow'] - links = model_json['Link'] + flows = model_json["Flow"] + links = model_json["Link"] templates = [] for flow in flows: # First identify parameters and stocks in the flow expression - params_in_expr = re.findall(r'p\.([^()*+-/ ]+)', flow['ϕf']) - stocks_in_expr = re.findall(r'u\.([^()*+-/ ]+)', flow['ϕf']) + params_in_expr = re.findall(r"p\.([^()*+-/ ]+)", flow["ϕf"]) + stocks_in_expr = re.findall(r"u\.([^()*+-/ ]+)", flow["ϕf"]) # We can now remove the prefixes from the expression - expression_str = flow['ϕf'].replace('p.', '').replace('u.', '') + expression_str = flow["ϕf"].replace("p.", "").replace("u.", "") # Turn each str symbol into a sympy.Symbol and add to dict of symbols # if not present before and also turn it into a Parameter object to be @@ -42,8 +66,9 @@ def template_model_from_stockflow_ascet_json(model_json) -> TemplateModel: for str_symbol in set(params_in_expr + stocks_in_expr): if symbols.get(str_symbol) is None: symbols[str_symbol] = sympy.Symbol(str_symbol) - mira_parameters[str_symbol] = \ - parameter_to_mira({"id": str_symbol}) + mira_parameters[str_symbol] = parameter_to_mira( + {"id": str_symbol} + ) # Process flow and links # Input stock to the flow is the 'u' field of the flow @@ -51,24 +76,27 @@ def template_model_from_stockflow_ascet_json(model_json) -> TemplateModel: # Does not handle multiple inputs or outputs of a flow currently # Doesn't use copy method as inputs/outputs of stock and flow diagram # are non-mutable (e.g. int), not mutable (e.g. lists) - input = flow['u'] - output = flow['d'] + input = flow["u"] + output = flow["d"] inputs = [] outputs = [] # flow_id or flow_name for template name? - flow_id = flow['_id'] # required - flow_name = flow.get('fname') + flow_id = flow["_id"] # required + flow_name = flow.get("fname") inputs.append(input) outputs.append(output) - used_stocks |= (set(inputs) | set(outputs)) + used_stocks |= set(inputs) | set(outputs) # A stock is considered a controller if it has a link to the given # flow but is not an input to the flow - controllers = [link['s'] for link in links if ( - link['t'] == flow_id and link['s'] != input)] + controllers = [ + link["s"] + for link in links + if (link["t"] == flow_id and link["s"] != input) + ] input_concepts = [concepts[i].copy(deep=True) for i in inputs] output_concepts = [concepts[i].copy(deep=True) for i in outputs] @@ -77,8 +105,15 @@ def template_model_from_stockflow_ascet_json(model_json) -> TemplateModel: expression_sympy = safe_parse_expr(expression_str, symbols) templates.extend( - transition_to_templates(input_concepts, output_concepts, controller_concepts, expression_sympy, flow_id, - flow_name)) + transition_to_templates( + input_concepts, + output_concepts, + controller_concepts, + expression_sympy, + flow_id, + flow_name, + ) + ) static_stocks = all_stocks - used_stocks @@ -86,46 +121,50 @@ def template_model_from_stockflow_ascet_json(model_json) -> TemplateModel: concept = concepts[state].copy(deep=True) templates.append(StaticConcept(subject=concept)) - return TemplateModel(templates=templates, - parameters=mira_parameters) + return TemplateModel(templates=templates, parameters=mira_parameters) def stock_to_concept(state): - name = state['_id'] - display_name = state.get('sname') - grounding = state.get('grounding', {}) - identifiers = grounding.get('identifiers', {}) - context = grounding.get('modifiers', {}) - units = state.get('units') + name = state["_id"] + display_name = state.get("sname") + grounding = state.get("grounding", {}) + identifiers = grounding.get("identifiers", {}) + context = grounding.get("modifiers", {}) + units = state.get("units") units_expr = get_sympy(units, UNIT_SYMBOLS) units_obj = Unit(expression=units_expr) if units_expr else None - return Concept(name=name, - display_name=display_name, - identifiers=identifiers, - context=context, - units=units_obj) + return Concept( + name=name, + display_name=display_name, + identifiers=identifiers, + context=context, + units=units_obj, + ) def parameter_to_mira(parameter): - """Return a MIRA parameter from a parameter""" - distr = Distribution(**parameter['distribution']) \ - if parameter.get('distribution') else None + distr = ( + Distribution(**parameter["distribution"]) + if parameter.get("distribution") + else None + ) data = { - "name": parameter['id'], - "display_name": parameter.get('name'), - "description": parameter.get('description'), - "value": parameter.get('value'), + "name": parameter["id"], + "display_name": parameter.get("name"), + "description": parameter.get("description"), + "value": parameter.get("value"), "distribution": distr, - "units": parameter.get('units') + "units": parameter.get("units"), } return Parameter.from_json(data) def main(): sfamr = requests.get( - 'https://raw.githubusercontent.com/AlgebraicJulia/' - 'py-acsets/jpfairbanks-patch-1/src/acsets/schemas/' - 'examples/StockFlowp.json').json() + "https://raw.githubusercontent.com/AlgebraicJulia/" + "py-acsets/jpfairbanks-patch-1/src/acsets/schemas/" + "examples/StockFlowp.json" + ).json() tm = template_model_from_stockflow_ascet_json(sfamr) sf_ascet = template_model_to_stockflow_ascet_json(tm) return tm diff --git a/mira/sources/amr/__init__.py b/mira/sources/amr/__init__.py index 609741eea..d1076b142 100644 --- a/mira/sources/amr/__init__.py +++ b/mira/sources/amr/__init__.py @@ -60,9 +60,9 @@ def model_from_json(model_json): """ header = model_json.get('header', {}) if 'schema' not in header: - raise ValueError(f'No schema defined in the AMR in {fname}. ' - f'The schema has to be a URL pointing to a ' - f'JSON schema against which the AMR is validated.') + raise ValueError(f"No schema defined in the AMR header. The schema " + f"has to be a URL pointing to a JSON schema " + f"against which the AMR is validated.") if 'petrinet' in header['schema']: return petrinet.template_model_from_amr_json(model_json) elif 'regnet' in header['schema']: @@ -74,6 +74,22 @@ def model_from_json(model_json): def sanity_check_amr(amr_json): + """Check that the AMR is valid + + Parameters + ---------- + amr_json : + The JSON object containing the AMR. + + Raises + ------ + AssertionError + If the AMR doesn't have a header or a schema. + jsonschema.exceptions.ValidationError + If the instance is invalid + jsonschema.exceptions.SchemaError + If the schema itself is invalid + """ assert 'header' in amr_json assert 'schema' in amr_json['header'] schema_json = requests.get(amr_json['header']['schema']).json() diff --git a/mira/sources/amr/flux_span.py b/mira/sources/amr/flux_span.py index d69942a07..8a166ecd0 100644 --- a/mira/sources/amr/flux_span.py +++ b/mira/sources/amr/flux_span.py @@ -15,8 +15,19 @@ from mira.modeling.amr.petrinet import AMRPetriNetModel -def reproduce_ode_semantics(flux_span): - """Reproduce ODE semantics from a stratified model (flux span).""" +def reproduce_ode_semantics(flux_span) -> TemplateModel: + """Reproduce ODE semantics from a stratified model (flux span) + + Parameters + ---------- + flux_span : + The AMR JSON object to reproduce ODE semantics from. + + Returns + ------- + : + The reproduced TemplateModel + """ # First we make the original template model tm = template_model_from_amr_json(flux_span) diff --git a/mira/sources/amr/regnet.py b/mira/sources/amr/regnet.py index 1052de5a2..8b196cd38 100644 --- a/mira/sources/amr/regnet.py +++ b/mira/sources/amr/regnet.py @@ -1,3 +1,6 @@ +"""This module implements parsing RegNet models defined in +https://github.com/DARPA-ASKEM/Model-Representations/tree/main/regnet. +""" __all__ = ["model_from_url", "model_from_json_file", "template_model_from_amr_json"] diff --git a/mira/sources/amr/stockflow.py b/mira/sources/amr/stockflow.py index 0ee392ab6..fc3588658 100644 --- a/mira/sources/amr/stockflow.py +++ b/mira/sources/amr/stockflow.py @@ -140,8 +140,19 @@ def template_model_from_amr_json(model_json) -> TemplateModel: time=model_time) -def stock_to_concept(stock): - """Return a Concept from a stock""" +def stock_to_concept(stock) -> Concept: + """Return a Concept from a stock + + Parameters + ---------- + stock : + A stock JSON object. + + Returns + ------- + : + The Concept corresponding to the provided stock. + """ name = stock['id'] display_name = stock.get('name') description = stock.get('description') diff --git a/mira/sources/biomodels.py b/mira/sources/biomodels.py index f7c3c4d0c..19f7cea81 100644 --- a/mira/sources/biomodels.py +++ b/mira/sources/biomodels.py @@ -4,6 +4,7 @@ """ import io import zipfile +from typing import Dict, List import pystow import requests @@ -65,10 +66,23 @@ def query_biomodels( query: str = "submitter_keywords:COVID-19", limit: int = 30, -): +) -> List[Dict[str, str]]: """Query and paginate over results from the BioModels API. .. seealso:: https://www.ebi.ac.uk/biomodels/docs/ + + Parameters + ---------- + query : + The query string to search for. Defaults to + "submitter_keywords:COVID-19". + limit : + The maximum number of results to return. Defaults to 30. + + Returns + ------- + : + A list of model metadata dictionaries. """ model_ids = set() res = requests.get( diff --git a/mira/sources/sbml/api.py b/mira/sources/sbml/api.py index b87d2ae22..ca48ce736 100644 --- a/mira/sources/sbml/api.py +++ b/mira/sources/sbml/api.py @@ -21,7 +21,24 @@ def template_model_from_sbml_file( model_id: Optional[str] = None, reporter_ids: Optional[Iterable[str]] = None, ) -> TemplateModel: - """Extract a MIRA template model from a file containing SBML XML.""" + """Extract a MIRA template model from a file containing SBML XML. + + Parameters + ---------- + file_path : + The path to the SBML file. + model_id : + The ID of the model to extract. (Optional) If not provided, an attempt + will be made to extract an ID from the SBML file if it's a BIOMODELS + model. + reporter_ids : + An iterable of reporter IDs + + Returns + ------- + : + The extracted MIRA template model. + """ sbml_model = sbml_model_from_file(file_path) return template_model_from_sbml_model(sbml_model, model_id=model_id, reporter_ids=reporter_ids) @@ -33,14 +50,42 @@ def template_model_from_sbml_file_obj( model_id: Optional[str] = None, reporter_ids: Optional[Iterable[str]] = None, ) -> TemplateModel: - """Extract a MIRA template model from a file object containing SBML XML.""" + """Extract a MIRA template model from a file object containing SBML XML. + + Parameters + ---------- + file : + The open file object containing the SBML XML. + model_id : + The ID of the model to extract. (Optional) If not provided, an attempt + will be made to extract an ID from the SBML file if it's a BIOMODELS + model. + reporter_ids : + An iterable of reporter IDs + + Returns + ------- + : + The extracted MIRA template model. + """ return template_model_from_sbml_string( file.read().decode("utf-8"), model_id=model_id, reporter_ids=reporter_ids ) def sbml_model_from_file(fname): - """Return an SBML model object from an SBML file.""" + """Return an SBML model object from an SBML file. + + Parameters + ---------- + fname : + The path to the SBML file. + + Returns + ------- + : + An SBML model object. + """ with open(fname, 'rb') as fh: sbml_string = fh.read().decode('utf-8') sbml_document = SBMLReader().readSBMLFromString(sbml_string) @@ -53,7 +98,24 @@ def template_model_from_sbml_string( model_id: Optional[str] = None, reporter_ids: Optional[Iterable[str]] = None, ) -> TemplateModel: - """Extract a MIRA template model from a string representing SBML XML.""" + """Extract a MIRA template model from a string representing SBML XML. + + Parameters + ---------- + s : + The string containing the SBML XML. + model_id : + The ID of the model to extract. (Optional) If not provided, an attempt + will be made to extract an ID from the SBML xml if it's a BIOMODELS + model. + reporter_ids : + An iterable of reporter IDs + + Returns + ------- + : + The extracted TemplateModel. + """ sbml_document = SBMLReader().readSBMLFromString(s) return template_model_from_sbml_model( sbml_document.getModel(), model_id=model_id, reporter_ids=reporter_ids @@ -66,7 +128,22 @@ def template_model_from_sbml_model( model_id: Optional[str] = None, reporter_ids: Optional[Iterable[str]] = None, ) -> TemplateModel: - """Extract a MIRA template model from an SBML model object.""" + """Extract a MIRA template model from an SBML model object. + + Parameters + ---------- + sbml_model : + The SBML model object. + model_id : + The ID of the model to extract. (Optional) If not provided, an attempt + will be made to extract an ID from the SBML model if it's a BIOMODELS + model. + + Returns + ------- + : + The extracted TemplateModel. + """ processor = SbmlProcessor(sbml_model, model_id=model_id, reporter_ids=reporter_ids) tm = processor.extract_model() diff --git a/mira/sources/util.py b/mira/sources/util.py index 6c41fdf4f..129a207be 100644 --- a/mira/sources/util.py +++ b/mira/sources/util.py @@ -1,112 +1,174 @@ -__all__ = ['transition_to_templates', 'get_sympy', 'parameter_to_mira'] +__all__ = ["transition_to_templates", "get_sympy", "parameter_to_mira"] import sympy from typing import Optional from mira.metamodel import * -def transition_to_templates(input_concepts, output_concepts, - controller_concepts, transition_rate, transition_id, transition_name=None): - """Return a list of templates from a transition""" +def transition_to_templates( + input_concepts, + output_concepts, + controller_concepts, + transition_rate, + transition_id, + transition_name=None, +): + """ + Return a list of templates from a transition. + + Parameters + ---------- + input_concepts : list[Concept] + A list of Concepts serving as input to a transition. + output_concepts : list[Concept] + A list of Concepts serving as output to a transition. + controller_concepts : list[Concept] + A list of Concepts serving as controllers towards a transition. + transition_rate : sympy.Expr + The rate law associated with the transition. + transition_id : str + The id of the transition. + transition_name : str + The name of the transition. + + Returns + ------- + : list[Template] + A list containing Templates. + """ if not controller_concepts: if not input_concepts: for output_concept in output_concepts: - yield NaturalProduction(outcome=output_concept, - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield NaturalProduction( + outcome=output_concept, + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) elif not output_concepts: for input_concept in input_concepts: - yield NaturalDegradation(subject=input_concept, - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield NaturalDegradation( + subject=input_concept, + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) else: for input_concept in input_concepts: for output_concept in output_concepts: - yield NaturalConversion(subject=input_concept, - outcome=output_concept, - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield NaturalConversion( + subject=input_concept, + outcome=output_concept, + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) else: if not (len(input_concepts) == 1 and len(output_concepts) == 1): if len(input_concepts) == 1 and not output_concepts: if len(controller_concepts) > 1: - yield GroupedControlledDegradation(controllers=controller_concepts, - subject=input_concepts[0], - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield GroupedControlledDegradation( + controllers=controller_concepts, + subject=input_concepts[0], + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) else: - yield ControlledDegradation(controller=controller_concepts[0], - subject=input_concepts[0], - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield ControlledDegradation( + controller=controller_concepts[0], + subject=input_concepts[0], + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) elif len(output_concepts) == 1 and not input_concepts: if len(controller_concepts) > 1: - yield GroupedControlledProduction(controllers=controller_concepts, - outcome=output_concepts[0], - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield GroupedControlledProduction( + controllers=controller_concepts, + outcome=output_concepts[0], + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) else: - yield ControlledProduction(controller=controller_concepts[0], - outcome=output_concepts[0], - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield ControlledProduction( + controller=controller_concepts[0], + outcome=output_concepts[0], + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) else: return [] elif len(controller_concepts) == 1: - yield ControlledConversion(controller=controller_concepts[0], - subject=input_concepts[0], - outcome=output_concepts[0], - rate_law=transition_rate, - name=transition_id, - display_name=transition_name) + yield ControlledConversion( + controller=controller_concepts[0], + subject=input_concepts[0], + outcome=output_concepts[0], + rate_law=transition_rate, + name=transition_id, + display_name=transition_name, + ) else: - yield GroupedControlledConversion(controllers=controller_concepts, - subject=input_concepts[0], - outcome=output_concepts[0], - rate_law=transition_rate, - display_name=transition_name) + yield GroupedControlledConversion( + controllers=controller_concepts, + subject=input_concepts[0], + outcome=output_concepts[0], + rate_law=transition_rate, + display_name=transition_name, + ) -def parameter_to_mira(parameter): - """Return a MIRA parameter from a parameter""" - distr = Distribution(**parameter['distribution']) \ - if parameter.get('distribution') else None +def parameter_to_mira(parameter) -> Parameter: + """ + Return a MIRA parameter from a mapping of MIRA Parameter attributes to + values. + + Parameters + ---------- + parameter : Dict[str,Any] + A mapping containing MIRA Parameter attributes to values. + + Returns + ------- + : + The corresponding MIRA Parameter. + """ + distr = ( + Distribution(**parameter["distribution"]) + if parameter.get("distribution") + else None + ) data = { - "name": parameter['id'], - "display_name": parameter.get('name'), - "description": parameter.get('description'), - "value": parameter.get('value'), + "name": parameter["id"], + "display_name": parameter.get("name"), + "description": parameter.get("description"), + "value": parameter.get("value"), "distribution": distr, # Note we handle empty dict below - "units": parameter.get('units') if parameter.get('units') else None + "units": parameter.get("units") if parameter.get("units") else None, } return Parameter.from_json(data) def get_sympy(expr_data, local_dict=None) -> Optional[sympy.Expr]: - """Return a sympy expression from a dict with an expression or MathML + """Return a sympy expression from a dict with an expression or MathML. Sympy string expressions are prioritized over MathML. Parameters ---------- - expr_data : - A dict with an expression and/or MathML - local_dict : - A dict of local variables to use when parsing the expression + expr_data : Dict[str,Any] + A dict with an expression and/or MathML. + local_dict : Dict[str, Any] + A dict of local variables to use when parsing the expression. Returns ------- : - A sympy expression or None if no expression was found + A sympy expression or None if no expression was found. """ if expr_data is None: return None diff --git a/mira/terarium_client.py b/mira/terarium_client.py index bbcde8926..b876bfadd 100644 --- a/mira/terarium_client.py +++ b/mira/terarium_client.py @@ -21,7 +21,20 @@ def associate(*, project_id: str, model_id: str) -> str: - """Associate a model (UUID) to a project (UUID) and return the association UUID.""" + """Associate a model (UUID) to a project (UUID) and return the association UUID + + Parameters + ---------- + project_id : + UUID of the project + model_id : + UUID of the model + + Returns + ------- + : + UUID of the association + """ x = f"http://data-service.staging.terarium.ai/projects/{project_id}/assets/models/{model_id}" res = requests.post(x) return res.json()["id"] @@ -39,6 +52,18 @@ def post_template_model( """Post a template model to Terarium as a Petri Net AMR. Optionally add to a project(s) if given. + + Parameters + ---------- + template_model : + TemplateModel to post + project_id : + UUID of the project to add model to (optional) + + Returns + ------- + : + TerariumResponse """ model = AMRPetriNetModel(Model(template_model)) amr_json = model.to_json() @@ -52,6 +77,18 @@ def post_amr( """Post an AMR to Terarium. Optionally add to a project(s) if given. + + Parameters + ---------- + amr : + AMR to post + project_id : + UUID of the project to add model to (optional) + + Returns + ------- + : + TerariumResponse """ res = requests.post( "http://data-service.staging.terarium.ai/models", json=amr @@ -84,11 +121,34 @@ def post_amr_remote( >>> "notebooks/evaluation_2023.07/eval_scenario3_base.json", >>> project_id="37", >>> ) + + Parameters + ---------- + model_url : + URL to download AMR from + project_id : + UUID of the project to add model to (optional) + + Returns + ------- + : + TerariumResponse """ model_amr_json = requests.get(model_url).json() return post_amr(model_amr_json, project_id=project_id) def get_template_model(model_id: str) -> TemplateModel: - """Get a template model from Terarium by its model UUID.""" + """Get a template model from Terarium by its model UUID + + Parameters + ---------- + model_id : + UUID of the model + + Returns + ------- + : + TemplateModel of the model + """ return model_from_url(f"http://data-service.staging.terarium.ai/models/{model_id}") diff --git a/tests/test_metamodel.py b/tests/test_metamodel.py index 50e06697f..8f0e05d32 100644 --- a/tests/test_metamodel.py +++ b/tests/test_metamodel.py @@ -46,7 +46,7 @@ def test_schema(self): self.assertEqual( get_json_schema(), json.loads(SCHEMA_PATH.read_text()), - msg="Regenerate an updated JSON schema by running `python -m mira.metamodel.templates`", + msg="Regenerate an updated JSON schema by running `python -m mira.metamodel.schema`", ) def test_controlled_conversion(self): diff --git a/tests/test_stockflow_ascet_source.py b/tests/test_stockflow_ascet_source.py index 3b0cece19..c6963d014 100644 --- a/tests/test_stockflow_ascet_source.py +++ b/tests/test_stockflow_ascet_source.py @@ -1,22 +1,22 @@ from copy import deepcopy as _d + +from mira.metamodel import ControlledConversion, NaturalConversion from mira.sources.acsets.stockflow import * import requests def set_up_file(): return requests.get( - 'https://raw.githubusercontent.com/AlgebraicJulia/' - 'py-acsets/jpfairbanks-patch-1/src/acsets/schemas/examples/StockFlowp.json').json() + "https://raw.githubusercontent.com/AlgebraicJulia/" + "py-acsets/jpfairbanks-patch-1/src/acsets/schemas/examples/StockFlowp.json" + ).json() def test_stock_to_concept(): - stock = { - "_id": 1, - "sname": "S" - } + stock = {"_id": 1, "sname": "S"} concept = stock_to_concept(stock) - assert concept.name == str(stock['_id']) - assert concept.display_name == stock['sname'] + assert concept.name == str(stock["_id"]) + assert concept.display_name == stock["sname"] def test_flow_to_template(): @@ -26,10 +26,10 @@ def test_flow_to_template(): assert len(tm.templates) == 2 assert isinstance(tm.templates[0], ControlledConversion) assert isinstance(tm.templates[1], NaturalConversion) - assert tm.templates[0].name == str(sf_ascet['Flow'][0]['_id']) - assert tm.templates[1].name == str(sf_ascet['Flow'][1]['_id']) - assert tm.templates[0].subject.name == str(sf_ascet['Flow'][0]['u']) - assert tm.templates[0].outcome.name == str(sf_ascet['Flow'][0]['d']) - assert tm.templates[0].controller.name == str(sf_ascet['Flow'][0]['d']) - assert tm.templates[1].subject.name == str(sf_ascet['Flow'][1]['u']) - assert tm.templates[1].outcome.name == str(sf_ascet['Flow'][1]['d']) + assert tm.templates[0].name == str(sf_ascet["Flow"][0]["_id"]) + assert tm.templates[1].name == str(sf_ascet["Flow"][1]["_id"]) + assert tm.templates[0].subject.name == str(sf_ascet["Flow"][0]["u"]) + assert tm.templates[0].outcome.name == str(sf_ascet["Flow"][0]["d"]) + assert tm.templates[0].controller.name == str(sf_ascet["Flow"][0]["d"]) + assert tm.templates[1].subject.name == str(sf_ascet["Flow"][1]["u"]) + assert tm.templates[1].outcome.name == str(sf_ascet["Flow"][1]["d"])