Source code for mira.metamodel.templates

"""
Data models for metamodel templates.

Regenerate the JSON schema by running ``python -m mira.metamodel.schema``.
"""
__all__ = [
    "Concept",
    "Template",
    "Provenance",
    "ControlledConversion",
    "ControlledProduction",
    "ControlledDegradation",
    "NaturalConversion",
    "NaturalProduction",
    "NaturalDegradation",
    "GroupedControlledConversion",
    "GroupedControlledProduction",
    "GroupedControlledDegradation",
    "NaturalReplication",
    "ControlledReplication",
    "StaticConcept",
    "SpecifiedTemplate",
    "templates_equal",
    "context_refinement",
    "match_concepts",
    "is_production",
    "is_degradation",
    "is_conversion",
    "is_replication",
    "has_subject",
    "has_outcome",
    "has_controller",
    "num_controllers",
    "get_binding_templates",
    "conversion_to_deg_prod",
]

import logging
import sys
from collections import ChainMap
from copy import deepcopy
from itertools import product
from typing import (
    Callable,
    ClassVar,
    Dict,
    List,
    Literal,
    Mapping,
    Optional,
    Set,
    Tuple,
    Union,
)

import networkx as nx
import pydantic
import sympy
from pydantic import BaseModel, Field

try:
    from typing import Annotated  # py39+
except ImportError:
    from typing_extensions import Annotated

from .units import Unit, UNIT_SYMBOLS
from .utils import safe_parse_expr, SympyExprStr


IS_EQUAL = "is_equal"
REFINEMENT_OF = "refinement_of"
CONTROLLERS = "controllers"
CONTROLLER = "controller"
OUTCOME = "outcome"
SUBJECT = "subject"

logger = logging.getLogger(__name__)


class Config(BaseModel):
    """Config determining how keys are generated"""

    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(
    prefix_priority=[
        "ido",
    ],
    prefix_exclusions=[
        "biomodels.species"
    ],
)


[docs]class Concept(BaseModel): """A concept is specified by its identifier(s), name, and - optionally - its context. """ name: str = Field(..., description="The name of the concept.") display_name: str = \ Field(None, description="An optional display name for the concept. " "If not provided, the name can be used for " "display purposes.") description: Optional[str] = \ Field(None, description="An optional description of the concept.") identifiers: Mapping[str, str] = Field( default_factory=dict, description="A mapping of namespaces to identifiers." ) context: Mapping[str, str] = Field( default_factory=dict, description="A mapping of context keys to values." ) units: Optional[Unit] = Field( None, description="The units of the concept." ) _base_name: str = pydantic.PrivateAttr(None) class Config: arbitrary_types_allowed = True json_encoders = { SympyExprStr: lambda e: str(e), } json_decoders = { SympyExprStr: lambda e: sympy.parse_expr(e) }
[docs] def with_context(self, do_rename=False, curie_to_name_map=None, inplace=False, **context) -> "Concept": """Return this concept with extra context. Parameters ---------- do_rename : If true, will modify the name of the node based on the context 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 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. inplace : bool If True, modify the concept in place. Default: False. **context : The context to add to the concept. Returns ------- : A new concept containing the given context. """ if do_rename: if self._base_name is None: self._base_name = self.name name_list = [self._base_name] for _, context_value in sorted(context.items()): entity_name = curie_to_name_map.get( context_value, context_value ) if curie_to_name_map else context_value name_list.append(str(entity_name.replace(':', '_'))) name = '_'.join(name_list) else: name = self.name full_context = dict(ChainMap(context, self.context)) if inplace: self.name = name self.context = full_context concept = self else: concept = Concept( name=name, display_name=self.display_name, identifiers=self.identifiers, context=full_context, units=self.units, ) concept._base_name = self._base_name return concept
[docs] def get_curie(self, config: Optional[Config] = None) -> Tuple[str, str]: """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() if k not in config.prefix_exclusions} # If ungrounded, return empty prefix and name if not identifiers: return "", self.name # If there are identifiers get one from the priority list for prefix in config.prefix_priority: identifier = identifiers.get(prefix) if identifier: return prefix, identifier # Fallback to the identifiers outside the priority list return sorted(identifiers.items())[0]
[docs] def get_curie_str(self, config: Optional[Config] = None) -> str: """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))
[docs] 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}
[docs] 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())), )
[docs] def is_equal_to(self, other: "Concept", with_context: bool = False, config: Config = None) -> bool: """Test for equality between concepts Parameters ---------- other : Other Concept to test equality with with_context : If True, do not consider the two Concepts equal unless they also have exactly the same context. If there is no context, ``with_context`` has no effect. config : Configuration defining priority and exclusion for identifiers. Returns ------- : True if ``other`` is the same Concept as this one """ if not isinstance(other, Concept): return False # With context if with_context: # Check that the same keys appear in both if set(self.context.keys()) != set(other.context.keys()): return False # Check that the values are the same for k1, v1 in self.context.items(): if v1 != other.context[k1]: return False # Check that they are grounded to the same identifier return self.get_curie(config=config) == other.get_curie(config=config)
[docs] def refinement_of( self, other: "Concept", refinement_func: Callable[[str, str], bool], with_context: bool = False, config: Config = None, ) -> bool: """Check if this Concept is a more detailed version of another Parameters ---------- other : The other Concept to compare with. Assumed to be less detailed. with_context : If True, also consider the context of the Concepts for the refinement. refinement_func : A function that given a source/more detailed entity and a target/less detailed entity checks if they are in a child-parent and returns a boolean. config : Configuration defining priority and exclusion for identifiers. Returns ------- : True if this Concept is a refinement of another Concept """ if not isinstance(other, Concept): return False # If they have equivalent identity, we allow as possible refinement if self.is_equal_to(other, with_context=False): ontological_refinement = True # Otherwise we assume refinement is not possible, except if both # are grounded, and there is a refinement relationship per # a refinement function between them else: ontological_refinement = False # Check if this concept is a child term to other? this_prefix, this_id = self.get_curie(config=config) other_prefix, other_id = other.get_curie(config=config) if this_prefix and other_prefix: # Check if other is a parent of this concept this_curie = f"{this_prefix}:{this_id}" other_curie = f"{other_prefix}:{other_id}" ontological_refinement = refinement_func(this_curie, other_curie) contextual_refinement = True if with_context: contextual_refinement = \ context_refinement(self.context, other.context) return ontological_refinement and contextual_refinement
[docs] @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 elif data.get('units'): data['units'] = Unit.from_json(data['units']) return cls(**data)
[docs]class Template(BaseModel): """The Template is a parent class for model processes""" class Config: arbitrary_types_allowed = True json_encoders = { SympyExprStr: lambda e: str(e), } json_decoders = { SympyExprStr: lambda e: safe_parse_expr(e) } 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." )
[docs] @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. # First, we need to figure out the template class based on the type # entry in the data stmt_cls = getattr(sys.modules[__name__], data['type']) # In order to correctly parse the rate, if any, we need to have access # to symbols representing parameters, these are passed in from # outside, typically the template model level. rate_str = data.get('rate_law') if rate_str: rate = safe_parse_expr(rate_str, local_dict=rate_symbols) else: rate = None # Handle concepts for concept_key in stmt_cls.concept_keys: if concept_key in data: concept_data = data[concept_key] # Handle lists of concepts for e.g. controllers in # GroupedControlledConversion if isinstance(concept_data, list): data[concept_key] = [Concept.from_json(c) for c in concept_data] else: data[concept_key] = Concept.from_json(data[concept_key]) return stmt_cls(**{k: v for k, v in data.items() if k not in {'rate_law', 'type'}}, rate_law=rate)
[docs] def is_equal_to(self, other: "Template", with_context: bool = False, config: Config = None) -> bool: """Check if this template is equal to another template Parameters ---------- other : The other template to check for equality with this one with with_context : If True, the contexts are taken into account when checking for equality. Default: False. config : Configuration defining priority and exclusion for identifiers. Returns ------- : True if the other Template is equal to this Template """ if not isinstance(other, Template): return False return templates_equal(self, other, with_context=with_context, config=config)
[docs] def refinement_of( self, other: "Template", refinement_func: Callable[[str, str], bool], with_context: bool = False, config: Config = None, ) -> bool: """Check if this template is a more detailed version of another Parameters ---------- other : The other template to compare with. Is assumed to be less detailed than this template. with_context : If True, also consider the context of Templates' Concepts for the refinement. refinement_func : A function that given a source/more detailed entity and a target/less detailed entity checks if they are in a child-parent relationship and returns a boolean. Returns ------- : True if this Template is a refinement of the other Template. """ if not isinstance(other, Template): return False compatibilities = { ('ControlledConversion', 'NaturalConversion'), ('GroupedControlledConversion', 'NaturalConversion'), ('GroupedControlledConversion', 'ControlledConversion') } if self.type != other.type and \ (self.type, other.type) not in compatibilities: return False other_by_role = other.get_concepts_by_role() for role, value in self.get_concepts_by_role().items(): # This is a special case to handle the list vs single controller # with distinct role names if role == 'controllers': if 'controllers' in other_by_role: other_value = other_by_role['controllers'] elif 'controller'in other_by_role: other_value = [other_by_role['controller']] else: other_value = None else: other_value = other_by_role.get(role) # This case handles less detailed other classes where a given # role might be missing if other_value is None: continue # When we are comparing concepts if isinstance(value, Concept): if not value.refinement_of(other_value, refinement_func=refinement_func, with_context=with_context, config=config): return False # When we are comparing lists of concepts elif isinstance(value, list): if not match_concepts(value, other_value, with_context=with_context, config=config, refinement_func=refinement_func): return False return True
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ): """Return a copy of this template with context added Parameters ---------- do_rename : If True, rename the names of the concepts exclude_concepts : A set of concept names to keep unchanged. curie_to_name_map : 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 ------- : A copy of this template with context added """ raise NotImplementedError("This method can only be called on subclasses")
[docs] def get_concepts(self) -> List[Union[Concept, 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]
[docs] def get_concepts_flat(self, exclude_controllers=False, refresh=False) -> List[Concept]: """Return the concepts in this template as a flat list. Attributes where a list of concepts is expected are flattened. """ concepts_flat = [] for role, value in self.get_concepts_by_role().items(): if role in {'controllers', 'controller'} and exclude_controllers: continue if isinstance(value, list): if refresh: setattr(self, role, [deepcopy(v) for v in value]) concepts_flat.extend(getattr(self, role)) else: if refresh: setattr(self, role, deepcopy(value)) concepts_flat.append(getattr(self, role)) return concepts_flat
[docs] 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 }
[docs] 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()}
[docs] def get_interactors(self) -> List[Concept]: """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']] elif 'controllers' in concepts_by_role: controllers = concepts_by_role['controllers'] else: controllers = [] subject = concepts_by_role.get('subject') interactors = controllers + ([subject] if subject else []) return interactors
[docs] def get_controllers(self) -> List[Concept]: """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']] elif 'controllers' in concepts_by_role: controllers = concepts_by_role['controllers'] else: controllers = [] return controllers
[docs] def get_interactor_rate_law(self, independent=False) -> sympy.Expr: """Return the rate law for the interactors in this template. 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: for interactor in self.get_interactors(): rate_law *= sympy.Symbol(interactor.name) else: concepts_by_role = self.get_concepts_by_role() subject = concepts_by_role.get('subject') controllers = self.get_controllers() rate_law *= sympy.Symbol(subject.name) controller_terms = 0 for controller in controllers: controller_terms += sympy.Symbol(controller.name) rate_law *= controller_terms return rate_law
[docs] def get_mass_action_rate_law(self, parameter: str, independent=False) -> sympy.Expr: """Return the mass action rate law for this template. Parameters ---------- parameter : The parameter to use for the mass-action rate law. independent : If True, the controllers will assume independent action. Returns ------- : The mass action rate law for this template. """ param_term = sympy.Symbol(parameter) if isinstance(parameter, str) \ else parameter rate_law = param_term * \ self.get_interactor_rate_law(independent=independent) return rate_law
[docs] 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
[docs] def set_mass_action_rate_law(self, parameter, independent=False): """Set the rate law of this template to a mass action rate law. Parameters ---------- parameter : The parameter to use for the mass-action rate. independent : If True, the controllers will assume independent action. """ self.rate_law = SympyExprStr( self.get_mass_action_rate_law(parameter, independent=independent))
[docs] def with_mass_action_rate_law(self, parameter, independent=False) -> "Template": """Return a copy of this template with a mass action rate law. Parameters ---------- parameter : The parameter to use for the mass-action rate. independent : If True, the controllers will assume independent action. Returns ------- : A copy of this template with the mass action rate law. """ template = self.copy(deep=True) template.set_mass_action_rate_law(parameter, independent=independent) return template
[docs] def set_rate_law(self, rate_law: Union[str, sympy.Expr, SympyExprStr], local_dict=None): """Set the rate law of this template to the given rate law.""" if isinstance(rate_law, SympyExprStr): self.rate_law = rate_law elif isinstance(rate_law, sympy.Expr): self.rate_law = SympyExprStr(rate_law) elif isinstance(rate_law, str): try: rate = SympyExprStr(safe_parse_expr(rate_law, local_dict=local_dict)) except Exception as e: logger.warning(f"Could not parse rate law into " f"symbolic expression: {rate_law}. " f"Not setting rate law.") return self.rate_law = rate
[docs] def with_rate_law(self, rate_law: Union[str, sympy.Expr, SympyExprStr], local_dict=None) -> "Template": template = self.copy(deep=True) template.set_rate_law(rate_law, local_dict=local_dict) return template
[docs] def get_parameter_names(self) -> Set[str]: """Get the set of parameter names. Returns ------- : The set of parameter names. """ if not self.rate_law: return set() return ( {s.name for s in self.rate_law.args[0].free_symbols} - self.get_concept_names() )
[docs] 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))
[docs] 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. 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()) if not results: return None if len(results) == 1: return sympy.Symbol(results[0]) raise ValueError("recovered multiple parameters - not mass action")
[docs] 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))
[docs] 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)
[docs] 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
[docs]class ControlledConversion(Template): """Specifies a process of controlled conversion from subject to outcome, controlled by the controller.""" type: Literal["ControlledConversion"] = Field("ControlledConversion", const=True) 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"]
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "ControlledConversion": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, subject=self.subject if self.subject.name in exclude_concepts else self.subject.with_context(do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context), outcome=self.outcome if self.outcome.name in exclude_concepts else self.outcome.with_context(do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context), controller=self.controller if self.controller.name in exclude_concepts else self.controller.with_context(do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context), provenance=self.provenance, rate_law=self.rate_law, )
[docs] def add_controller(self, controller: Concept) -> "GroupedControlledConversion": """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, provenance=self.provenance, controllers=[self.controller, controller] )
[docs] def with_controller(self, controller) -> "ControlledConversion": """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, subject=self.subject, outcome=self.outcome, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.subject.get_key(config=config), self.controller.get_key(config=config), self.outcome.get_key(config=config), )
[docs]class GroupedControlledConversion(Template): type: Literal["GroupedControlledConversion"] = Field("GroupedControlledConversion", const=True) 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"]
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "GroupedControlledConversion": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, controllers=[c.with_context(do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context) if c.name not in exclude_concepts else c for c in self.controllers], subject=self.subject if self.subject.name in exclude_concepts else self.subject.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ), outcome=self.outcome if self.outcome.name in exclude_concepts else self.outcome.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ), provenance=self.provenance, rate_law=self.rate_law, )
[docs] def with_controllers(self, controllers) -> "GroupedControlledConversion": """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( f"Must replace all controllers. Expecting " f"{len(self.controllers)} controllers, got {len(controllers)}" ) return self.__class__( type=self.type, controllers=controllers, subject=self.subject, outcome=self.outcome, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, *tuple( c.get_key(config=config) for c in sorted(self.controllers, key=lambda c: c.get_key(config=config)) ), self.subject.get_key(config=config), self.outcome.get_key(config=config), )
[docs] def get_concepts(self): return self.controllers + [self.subject, self.outcome]
[docs] def add_controller(self, controller: Concept) -> "GroupedControlledConversion": """Add an additional controller.""" return GroupedControlledConversion( subject=self.subject, outcome=self.outcome, provenance=self.provenance, controllers=[*self.controllers, controller] )
[docs]class GroupedControlledProduction(Template): """Specifies a process of production controlled by several controllers""" type: Literal["GroupedControlledProduction"] = Field("GroupedControlledProduction", const=True) 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"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, *tuple( c.get_key(config=config) for c in sorted(self.controllers, key=lambda c: c.get_key(config=config)) ), self.outcome.get_key(config=config), )
[docs] def get_concepts(self): return self.controllers + [self.outcome]
[docs] def add_controller(self, controller: Concept) -> "GroupedControlledProduction": """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, controllers=[*self.controllers, controller] )
[docs] def with_controllers(self, controllers) -> "GroupedControlledProduction": """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, outcome=self.outcome, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "GroupedControlledProduction": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, controllers=[c.with_context(do_rename, curie_to_name_map=curie_to_name_map, **context) if c.name not in exclude_concepts else c for c in self.controllers], outcome=self.outcome.with_context( do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.outcome.name not in exclude_concepts else self.outcome, provenance=self.provenance, rate_law=self.rate_law, )
[docs]class ControlledProduction(Template): """Specifies a process of production controlled by one controller""" 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"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.controller.get_key(config=config), self.outcome.get_key(config=config), )
[docs] def add_controller(self, controller: Concept) -> "GroupedControlledProduction": """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, controllers=[self.controller, controller] )
[docs] def with_controller(self, controller) -> "ControlledProduction": """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, outcome=self.outcome, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "ControlledProduction": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, outcome=self.outcome.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.outcome.name not in exclude_concepts else self.outcome, controller=self.controller.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.controller.name not in exclude_concepts else self.controller, provenance=self.provenance, rate_law=self.rate_law, )
[docs]class NaturalConversion(Template): """Specifies a process of natural conversion from subject to outcome""" type: Literal["NaturalConversion"] = Field("NaturalConversion", const=True) 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"]
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "NaturalConversion": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, subject=self.subject.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.subject.name not in exclude_concepts else self.subject, outcome=self.outcome.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.outcome.name not in exclude_concepts else self.outcome, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.subject.get_key(config=config), self.outcome.get_key(config=config), )
[docs]class NaturalProduction(Template): """A template for the production of a species at a constant rate.""" type: Literal["NaturalProduction"] = Field("NaturalProduction", const=True) 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"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.outcome.get_key(config=config), )
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "NaturalProduction": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, outcome=self.outcome.with_context(do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context) if self.outcome.name not in exclude_concepts else self.outcome, provenance=self.provenance, rate_law=self.rate_law, )
[docs]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 = 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"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.subject.get_key(config=config), )
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "NaturalDegradation": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, subject=self.subject.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.subject.name not in exclude_concepts else self.subject, provenance=self.provenance, rate_law=self.rate_law, )
[docs]class ControlledDegradation(Template): """Specifies a process of degradation controlled by one controller""" type: Literal["ControlledDegradation"] = Field("ControlledDegradation", const=True) 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"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.controller.get_key(config=config), self.subject.get_key(config=config), )
[docs] def add_controller(self, controller: Concept) -> "GroupedControlledDegradation": """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], provenance=self.provenance, )
[docs] def with_controller(self, controller) -> "ControlledDegradation": """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, subject=self.subject, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "ControlledDegradation": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, subject=self.subject.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.subject.name not in exclude_concepts else self.subject, controller=self.controller.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.controller.name not in exclude_concepts else self.controller, provenance=self.provenance, rate_law=self.rate_law, )
[docs]class GroupedControlledDegradation(Template): """Specifies a process of degradation controlled by several controllers""" type: Literal["GroupedControlledDegradation"] = Field("GroupedControlledDegradation", const=True) 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"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, *tuple( c.get_key(config=config) for c in sorted(self.controllers, key=lambda c: c.get_key(config=config)) ), self.subject.get_key(config=config), )
[docs] def get_concepts(self): return self.controllers + [self.subject]
[docs] def add_controller(self, controller: Concept) -> "GroupedControlledDegradation": """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, controllers=[*self.controllers, controller] )
[docs] def with_controllers(self, controllers) -> "GroupedControlledDegradation": """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, subject=self.subject, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "GroupedControlledDegradation": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, controllers=[c.with_context(do_rename, curie_to_name_map=curie_to_name_map, **context) if c.name not in exclude_concepts else c for c in self.controllers], subject=self.subject.with_context(do_rename, curie_to_name_map=curie_to_name_map, **context) if self.subject.name not in exclude_concepts else self.subject, provenance=self.provenance, rate_law=self.rate_law, )
[docs]class NaturalReplication(Template): """Specifies a process of natural replication of a subject.""" type: Literal["NaturalReplication"] = Field("NaturalReplication", const=True) subject: Concept = Field(..., description="The subject of the replication.") provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the template.") concept_keys: ClassVar[List[str]] = ["subject"]
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "NaturalReplication": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, subject=self.subject.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.subject.name not in exclude_concepts else self.subject, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.subject.get_key(config=config), )
[docs]class ControlledReplication(Template): """Specifies a process of replication controlled by one controller""" type: Literal["ControlledReplication"] = Field("ControlledReplication", const=True) controller: Concept = Field(..., description="The controller of the replication.") subject: Concept = Field(..., description="The subject of the replication.") provenance: List[Provenance] = Field(default_factory=list, description="The provenance of the replication.") concept_keys: ClassVar[List[str]] = ["controller", "subject"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.controller.get_key(config=config), self.subject.get_key(config=config), )
[docs] def with_controller(self, controller) -> "ControlledReplication": """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, subject=self.subject, provenance=self.provenance, rate_law=self.rate_law, )
[docs] def with_context( self, do_rename=False, exclude_concepts=None, curie_to_name_map=None, **context ) -> "ControlledReplication": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, subject=self.subject.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.subject.name not in exclude_concepts else self.subject, controller=self.controller.with_context( do_rename=do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.controller.name not in exclude_concepts else self.controller, provenance=self.provenance, rate_law=self.rate_law, )
[docs]class StaticConcept(Template): """Specifies a standalone Concept that is not part of a process.""" type: Literal["StaticConcept"] = Field("StaticConcept", const=True) subject: Concept = Field(..., description="The subject.") provenance: List[Provenance] = Field(default_factory=list, description="The provenance.") concept_keys: ClassVar[List[str]] = ["subject"]
[docs] def get_key(self, config: Optional[Config] = None): return ( self.type, self.subject.get_key(config=config), )
[docs] def get_concepts(self): return [self.subject]
[docs] def with_context( self, do_rename=False, curie_to_name_map=None, exclude_concepts=None, **context ) -> "StaticConcept": exclude_concepts = exclude_concepts or set() return self.__class__( type=self.type, subject=(self.subject.with_context( do_rename, curie_to_name_map=curie_to_name_map, **context ) if self.subject.name not in exclude_concepts else self.subject), provenance=self.provenance, rate_law=self.rate_law, )
[docs]def templates_equal(templ: Template, other_templ: Template, with_context: bool, config: Config) -> bool: """Check if two Template objects are equal Parameters ---------- templ : A template to compare. other_templ : The other template to compare. with_context : If True, also check the contexts of the contained Concepts of the Template. config : Configuration defining priority and exclusion for identifiers. Returns ------- : True if the two Template objects are equal. """ if templ.type != other_templ.type: return False other_by_role = other_templ.get_concepts_by_role() for role, value in templ.get_concepts_by_role().items(): other_value = other_by_role.get(role) if isinstance(value, Concept): if not value.is_equal_to(other_value, with_context=with_context, config=config): return False elif isinstance(value, list): if len(value) != len(other_value): return False if not match_concepts(value, other_value, with_context=with_context, config=config): return False return True
[docs]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 \ product(enumerate(self_concepts), enumerate(other_concepts)): if refinement_func: res = self_concept.refinement_of(other_concept, with_context=with_context, refinement_func=refinement_func, config=config) else: res = self_concept.is_equal_to(other_concept, with_context=with_context, config=config) if res: G.add_edge('S%d' % self_idx, 'O%d' % other_idx) # Then find a maximal matching in the bipartite graph match = nx.algorithms.max_weight_matching(G) # If all the other concepts are covered, this is considered a match. # The reason for checking this as a condition is that this works for # both the equality case where the two lists have the same length, and # the refinement case where we want to find a match/refinement for # each of the concepts in the other list. return len(match) == len(other_concepts)
[docs]def context_refinement(refined_context, other_context) -> bool: """Check if one Concept's context is a refinement of another Concept's Parameters ---------- refined_context : The assumed *more* detailed context other_context : The assumed *less* detailed context Returns ------- : True if the Concept `refined_concept` truly is strictly more detailed than `other_concept` """ # 1. True if no context for both if not refined_context and not other_context: return True # 2. True if refined concept has context and the other one not elif refined_context and not other_context: return True # 3. False if refined concept does not have context and the other does elif not refined_context and other_context: return False # 4. Both have context, in which case we need to make sure there is no # explicit difference for any key/value pair that exists in other. This # means that the refined context can have additional keys/values, or # the two contexts can be exactly equal else: for other_key, other_val in other_context.items(): if refined_context.get(other_key) != other_val: return False return True
# Needed for proper parsing by FastAPI SpecifiedTemplate = Annotated[ Union[ NaturalConversion, ControlledConversion, NaturalDegradation, ControlledDegradation, GroupedControlledDegradation, NaturalProduction, ControlledProduction, GroupedControlledConversion, GroupedControlledProduction, NaturalReplication, ControlledReplication, StaticConcept, ], Field(description="Any child class of a Template", discriminator="type"), ] def has_specific_controller(template: Template, controller: Concept) -> bool: """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. """ concepts_by_role = template.get_concepts_by_role() if 'controller' in concepts_by_role: return template.controller == controller elif 'controllers' in concepts_by_role: return any(c == controller for c in template.controllers) else: raise NotImplementedError( f"Template {template.type} is not a controlled process" )
[docs]def has_controller(template: Template) -> bool: """Check if the template has a controller. Parameters ---------- template : The template to check. The template must be representing a controlled process. Returns ------- : True if the template has a controller """ if {'controller', 'controllers'} & set(template.get_concepts_by_role()): return True else: return False
[docs]def is_production(template): """Return True if the template is a form of production.""" return isinstance(template, (NaturalProduction, ControlledProduction, GroupedControlledProduction))
[docs]def is_degradation(template): """Return True if the template is a form of degradation.""" return isinstance(template, (NaturalDegradation, ControlledDegradation, GroupedControlledDegradation))
[docs]def is_replication(template): """Return True if the template is a form of replication.""" return isinstance(template, (NaturalReplication, ControlledReplication))
[docs]def is_conversion(template): """Return True if the template is a form of conversion.""" return isinstance(template, (NaturalConversion, ControlledConversion, GroupedControlledConversion))
[docs]def has_outcome(template): """Return True if the template has an outcome.""" return is_production(template) or is_conversion(template)
[docs]def has_subject(template): """Return True if the template has a subject.""" return (is_conversion(template) or is_degradation(template) or is_replication(template))
[docs]def num_controllers(template): """Return the number of controllers in the template.""" if isinstance(template, (ControlledConversion, ControlledProduction, ControlledDegradation, ControlledReplication)): return 1 elif isinstance(template, (GroupedControlledConversion, GroupedControlledProduction, GroupedControlledDegradation)): return len(template.controllers) else: return 0
[docs]def get_binding_templates(a, b, c, kf, kr): """Return a list of templates emulating a reversible binding process.""" af = lambda: Concept(name=a) bf = lambda: Concept(name=b) cf = lambda: Concept(name=c) templates = [ GroupedControlledProduction(controllers=[af(), bf()], outcome=cf()).with_mass_action_rate_law(kf), ControlledDegradation(controller=af(), subject=bf()).with_mass_action_rate_law(kf), ControlledDegradation(controller=bf(), subject=af()).with_mass_action_rate_law(kf), NaturalDegradation(subject=cf()).with_mass_action_rate_law(kr), ControlledProduction(controller=cf(), outcome=af()).with_mass_action_rate_law(kr), ControlledProduction(controller=cf(), outcome=bf()).with_mass_action_rate_law(kr) ] return templates
[docs]def conversion_to_deg_prod(conv_template): """Given a conversion template, compile into degradation/production templates.""" if not is_conversion(conv_template): return [conv_template] nc = num_controllers(conv_template) if nc == 0: tdeg = NaturalDegradation(subject=conv_template.subject, rate_law=conv_template.rate_law) tprod = ControlledProduction(outcome=conv_template.outcome, controller=conv_template.subject, rate_law=conv_template.rate_law) elif nc == 1: tdeg = ControlledDegradation(subject=conv_template.subject, controller=conv_template.controller, rate_law=conv_template.rate_law) tprod = GroupedControlledProduction(outcome=conv_template.outcome, controllers=[conv_template.controller, conv_template.subject], rate_law=conv_template.rate_law) else: tdeg = GroupedControlledDegradation(subject=conv_template.subject, controllers=conv_template.controllers, rate_law=conv_template.rate_law) tprod = GroupedControlledProduction(outcome=conv_template.outcome, controllers=conv_template.controllers + [conv_template.subject], rate_law=conv_template.rate_law) return deepcopy([tdeg, tprod])