Interacting with MIRA template models using Python code

Table of Contents

Access model attributes

Templates

Templates are stored in the templates attribute of template model objects

Users can index on the list of templates or iterate throughout the list of templates to access templates.

Example: Accessing templates through list indexing and iteration

from mira.examples.sir import sir_petrinet as sir 

# access the second template using list indexing
template = sir.templates[1]

# go through all the templates in the model using a for loop
for template in sir.templates:
    pass

Template attributes

All template objects have 3 optional attributes

Creating rate-laws

The least error-prone way to create a rate-law to be used in a template is to use the safe_parse_expr method while passing in a local_dict mapping of symbols used in the rate law to their sympy equivalent. The returned expression from safe_parse_expr can either be used as stand-alone expression to be used in templates or passed to the SympyExprStr constructor.

Creating a rate-law using safe_parse_expr

import sympy

from mira.metamodel.utils import safe_parse_expr
# Create symbols to be used as values in the local_dict mapping
beta_symbol = sympy.Symbol("beta")
mu_symbol = sympy.Symbol("mu")

# Create the string expression to be parsed and converted to a sympy expression
str_expression = "5*beta*mu"

# Define the mapping of symbols
local_dict = {"beta": beta_symbol, "mu": mu_symbol}

# Create a sympy.Expr object 
expression = safe_parse_expr(str_expression, local_dict)

The expression created can now be passed into any method or constructor that requires a rate-law. We can also pass the result of safe_parse_expr to the SympyExprStr constructor which accepts types str, float,int, and sympy.Expr to convert it to a SympyExprStr object which is a subclass of the sympy.Expr class.
sympy.Expr and SympyExprStr objects can be used interchangeably.

Additionally, there will be code examples used in this document that pass in a sympy.Symbol, sympy.Float, or sympy.Integer object to an argument that expects a sympy.Expr or SympyExprStr object. This is allowed because the three former sympy object classes mentioned are all subclasses of sympy.Expr.

Creating a rate-law using safe_parse_expr and passing it into SympyExprStr

import sympy

from mira.metamodel.utils import SympyExprStr, safe_parse_expr

beta_symbol = sympy.Symbol("beta")
mu_symbol = sympy.Symbol("mu")
str_expression = "5*beta*mu"
local_dict = {"beta": beta_symbol, "mu": mu_symbol}
expression = safe_parse_expr(str_expression, local_dict)

# We now have a SympyExprStr expression that can be used interchangeably with the result from safe_parse_expr
expression = SympyExprStr(expression)

Template types

Template controllers

Template operations

Retrieve template information
Get all the concepts present in a template

We can extract all the concepts in template by using the get_concepts method.

Example: Return a list of all concepts in the template

from mira.examples.sir import sir_petrinet as sir 

concepts_list = sir.templates[0].get_concepts()
Get all the controllers present in a template

We can get all the controllers in a template by employing the get_controllers method.

Example: Get all the controllers present in a template

from mira.examples.sir import sir_petrinet as sir 

controller_list = sir.templates[0].get_controllers()
Template modifications
Rewrite a template rate-law

We can change the rate law of a template using the template instance method set_rate_law.

Example: Setting a custom rate-law for a template

import sympy 

from mira.metamodel.utils import SympyExprStr,safe_parse_expr
from mira.examples.sir import sir_petrinet as sir 

# Define a dictionary of symbols present in the new rate-law to sympy symbol objects
# for parsing of rate-law strings
local_dict = {"I": sympy.Symbol("I"), "beta": sympy.Symbol("beta")}

# We can just pass in the return value from safe_parse_expr as well
# The SympyExprStr constructor can take in a string, int, float, or sympy.Expr object 
sir.templates[0].set_rate_law(SympyExprStr(safe_parse_expr("I*beta", local_dict)))
Rename a parameter in a template rate-law

We can update the names of parameters in a rate law using the template instance method update_parameter_name.

Example: Updating the parameter name in a template’s rate-law

from mira.examples.sir import sir_petrinet as sir 

sir.templates[0].update_parameter_name("beta", "sigma")

Observables

Observables associated with a template model can be accessed using the observables attribute of a template model object.

Observable operations

Add an observable

A user might want to add a new observable to keep track of a new combination of compartment values

Users can define a key-value pair where the key represents the id of the observable and the value is a newly created observable object to add to the template model. We create a new observable object with name and expression to keep track of the total number of infected in a SIR epidemiology model.

If there already exists a key-value pair in the observables dictionary using the same key, then the old observable object will be overwritten by the new one.

Example: Adding a single observable using key-based indexing

import sympy 

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

key = "total_infections"

# Since the expression is only represented by a single symbol "I", we can pass in a 
# sympy.Symbol object to the expression field when creating the observable
expression = sympy.Symbol("I")
total_infections_observable = Observable(name=key, expression=expression)

sir.observables[key] = total_infections_observable

Users can also add multiple observables at once using the Python dictionary update method. The update method is a dictionary instance method that can take in another dictionary and combines both dictionaries.

The passed-in dictionary takes priority and will overwrite the key-value pairs of the original dictionary if they share the same key.

Example: Adding multiple observables using the dictionary update method

import sympy 

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

key_infections = "total_infections"
expression_infections = sympy.Symbol("I")
total_infections_observable = Observable(name=key_infections,
                                         expression=expression_infections)

key_susceptible = "total_susceptible"
expression_susceptible = sympy.Symbol("S")
total_susceptible_observable = Observable(name=key_susceptible,
                                          expression=expression_susceptible)

new_observables = {key_infections: total_infections_observable,
                   key_susceptible: total_susceptible_observable}

sir.observables.update(new_observables)
Remove an observable

A user might want to remove an observable because it’s no longer needed.

We can utilize the dictionary pop method that takes in a key and removes the key-value pair from the dictionary if it exists in the dictionary.

Example: Removing an observable using the dictionary pop method

import sympy 

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

key = "total_infections"
expression = sympy.Symbol("I")
total_infections_observable = Observable(name=key, expression=expression)

sir.observables[key] = total_infections_observable

sir.observables.pop(key)
Modify an observable expression

A user might want to modify the expression of an observable to keep track of a different combination of compartments

We can use the Python dictionary method get on the observables dictionary which takes in a key and returns a reference to the observable object that we’d like to modify if its key exists in the observables dictionary.

Example: Modifying the expression of an existing observable

import sympy 

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

# Add the observable
key = "total_infections"
expression = sympy.Symbol("I")
total_infections_observable = Observable(name=key, expression=expression)

sir.observables[key] = total_infections_observable

# stratify to add a species specific strata for the infected compartment
model = stratify(sir, "species", ["human", "pet"], concepts_to_stratify=["I"])

local_dict = {"I_human": sympy.Symbol("I_human"), "I_pet": sympy.Symbol("I_pet")}

# keep track of both human and pet infections for the total number of infected
new_expression = safe_parse_expr("I_human+I_pet", local_dict)

sir.observables.get(key).expression = SympyExprStr(new_expression)

Initials

Initials associated with a template model can be accessed using the initials attribute of a template model object.

Initial operations

Add an initial

A user might want to add a new initial to introduce a starting value for a compartment for simulation purposes.

Users can define a key-value pair where the key represents the id of the initial and the value is a newly created initial object to add to the template model. We create a new initial object with name and expression to keep track of the total number of infected in a SIR epidemiology model.

If there already exists a key-value pair in the initial dictionary using the same key, then the old initial object will be overwritten by the new one.

Example: Adding a single initial for the susceptible compartment in a SIR model using key-based indexing

import sympy

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

susceptible_concept = sir.get_concept("S")
key_susceptible = susceptible_concept.name

# Though initial values for compartments can be numbers, the Python object type
# passed into the expression argument for the Initial constructor must be of type
# (SympyExprStr, sympy.Expr), sympy.Float and sympy.Integer are subclasses of sympy.Expr
initial_expression = SympyExprStr(sympy.Float(1000))

# The Initial constructor takes in a concept object 
susceptible_initial = Initial(concept=susceptible_concept,
                              expression=initial_expression)

sir.initials[key_susceptible] = susceptible_initial

Users can also add multiple initials at once using the Python dictionary update method. The update method is a dictionary instance method that can take in another dictionary and combines both dictionaries.

The passed-in dictionary takes priority and will overwrite the key-value pairs of the original dictionary if they share the same key.

Example: Adding multiple initials using the dictionary update method

import sympy

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

susceptible_concept = sir.get_concept("S")
infected_concept = sir.get_concept("I")
key_susceptible = susceptible_concept.name
key_infected = infected_concept.name 

susceptible_initial_expression = SympyExprStr(sympy.Float(1000))
infected_initial_expression = SympyExprStr(sympy.Float(0))

susceptible_initial = Initial(concept=susceptible_concept,
                              expression=susceptible_initial_expression)
infection_initial = Initial(concept=infected_concept,
                            expression=infected_initial_expression)

sir.initials[key_susceptible] = susceptible_initial
sir.initials[key_infected] = infection_initial


new_initials = {key_susceptible: susceptible_initial,
                   key_infected: infection_initial}

sir.initials.update(new_initials)
Remove an initial

A user might want to remove an initial because the compartment value it represents is no longer used for simulation purposes.

We can utilize the dictionary pop method that takes in a key and removes the key-value pair from the dictionary if it exists in the dictionary.

Example: Removing an initial using the dictionary pop method

import sympy

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

susceptible_concept = sir.get_concept("S")
key_susceptible = susceptible_concept.name

susceptible_initial_expression = SympyExprStr(sympy.Float(5))
susceptible_initial = Initial(concept=susceptible_concept,
                              expression=susceptible_initial_expression)

sir.initials[key_susceptible] = susceptible_initial

sir.initials.pop(key_susceptible)
Modify an initial expression

A user might want to modify the initial of an expression to change the starting value for a compartment during simulation.

We can use the Python dictionary method get on the initials dictionary which takes in a key and returns a reference to the initial object that we’d like to modify if its key exists in the initials dictionary.

There are two types of values that an initial object’s expression can take. Users can either pass in a value or parameter to an initial expression to represent the initial value for a compartment.

Set an initial expression to a number

Though we can use a number to represent the initial expression semantically, we must pass in a sympy object to the expression field for the Initial constructor.

Example: Setting the expression of an initial to be represented by a number

import sympy 

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir 

susceptible_concept = sir.get_concept("S")
key_susceptible = susceptible_concept.name

sir.initials.get(key_susceptible).expression = SympyExprStr(sympy.Float(1000))
Set an initial expression to an expression

We can define the expression of an initial to be represented by an actual expression.

Example: Setting the expression of an initial to be represented by a parameter

import sympy

from mira.metamodel import *
from mira.examples.sir import sir_petrinet as sir

susceptible_concept = sir.get_concept("S")
key_susceptible = susceptible_concept.name

# Add the parameter that represents the initial value for the susceptible compartment
# to the template model 
sir.add_parameter(parameter_id="S0", value=1000)
local_dict = {"S0": sympy.Symbol("S0")}

# Set the parameter "S0" to represent the initial value for the susceptible compartment
sir.initials.get(key_susceptible).expression = SympyExprStr(
  safe_parse_expr("S0", local_dict))

Template Model operations

Retrieve concepts

There are multiple ways in which a user can retrieve concepts present in a template model object. We can either retrieve a single concept object by name or return a mapping of concept keys to concept objects.

Retrieve a concept by name

We can use the get_concept method to return a single concept object.

Example: Retrieve a single concept object by name

from mira.examples.sir import sir_petrinet as sir 

susceptible_concept = sir.get_concept("S")

Retrieve the concept map

If we want to retrieve all the concepts present in a template model, we can use the get_concepts_map method to return a mapping of concepts.

Example: Return the mapping of concepts in a template model

from mira.examples.sir import sir_petrinet as sir

concepts_map = sir.get_concepts_map()

Add a parameter

Users can use the add_parameter method which is a template model instance method that adds a parameter to the template model. The only required parameter is the id of the parameter.

Example: Adding a parameter only using a parameter id to the template model

from mira.examples.sir import sir_petrinet as sir 

sir.add_parameter("mu")

Common use-cases

If we added pet-specific compartments to a human-centric SIR epidemiology model, but don’t have accompanying parameters, we can add pet specific parameters with values for simulation purposes.

Example: Add a new parameter with a value to the template model

from mira.examples.sir import sir_petrinet as sir 

sir.add_parameter("mu_pet", value=0.0003)

Add a template

There are three ways that a template can be added to a template model object. A user might want to add a template to an existing template model object to extend its capabilities or customize the model to fit the specific problem scenario.

Add a template using add_template

We can use the add_template template model instance method to add a template.

Example: Using the add_template method to add a template to the model

import sympy

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.templates import *
from mira.metamodel.template_model import *
from mira.metamodel.utils import SympyExprStr, safe_parse_expr

recovered_concept = sir.get_concept("R")

local_dict = {"eta": sympy.Symbol("eta"), "R": sympy.Symbol("R")}
# Define the template to add 
template = NaturalConversion(subject=recovered_concept,
                             outcome=sir.get_concept("I"),
                             rate_law=safe_parse_expr("eta*R", local_dict))

# Add the new template "eta" that appears in the added rate law 
parameter_mapping = {"eta": Parameter(name="eta", value=5)}

# Update the initial for the recovered compartment 
initial_mapping = {"R": Initial(concept=recovered_concept,
                                 expression=SympyExprStr(sympy.Float(5)))}

sir = sir.add_template(template, parameter_mapping=parameter_mapping,
                 initial_mapping=initial_mapping)

Add a template using add_transition

We can use the add_transition template model instance method to infer the template type to be added from the arguments passed and add it to the template model. Currently, this method only supports adding natural type templates.

Example: Using the add_transition method to add a template to the model

import sympy 

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.utils import SympyExprStr, safe_parse_expr

recovered_concept = sir.get_concept("I")
susceptible_concept = sir.get_concept("S")

local_dict = {"eta": sympy.Symbol("eta"), "R": sympy.Symbol("R")}

rate_law_sympy = SympyExprStr(safe_parse_expr("eta*R", local_dict))
params_dict = {"eta": {"display_name": "eta", "value": 5}}

sir = sir.add_transition(subject_concept=recovered_concept, outcome_concept=susceptible_concept,
                   rate_law_sympy=rate_law_sympy, params_dict=params_dict)

Add a template to the list of templates stored in the templates attribute of a template model object

Rather than using a method, users can also add templates by directly appending a template to the templates attribute which is a list of templates.

Example: Adding a template directly accessing the templates attribute

import sympy

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.templates import *
from mira.metamodel.utils import SympyExprStr, safe_parse_expr

recovered_concept = sir.get_concept("R")

local_dict = {"eta": sympy.Symbol("eta"), "R": sympy.Symbol("R")}

# Define the template to add 
template = NaturalConversion(subject=recovered_concept,
                             outcome=sir.get_concept("I"),
                             rate_law=SympyExprStr(
                               safe_parse_expr("R*eta", local_dict)))

sir.templates.append(template)

# After the template it added, manually add the new parameter that appears in the template
sir.add_parameter(parameter_id="eta", value=.02)

Stratification

The stratification method can take in an exhaustive list of arguments and multiplies a template model into several strata.

The three required arguments are the input template model, key, and strata

Example: Stratification on a basic SIR model by vaccination status

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.ops import stratify

key = "vaccination_status"
strata = ["unvaccinated", "vaccinated"]
sir = stratify(sir, key, strata)

Common use-cases

Select concepts and parameters to stratify

Example: Stratification on a SIR model while selecting certain concepts and parameters to stratify

from mira.examples.sir import sir_petrinet as sir 
from mira.metamodel.ops import stratify

key = "vaccination_status"
strata = ["unvaccinated", "vaccinated"]
sir = stratify(sir, key, strata, concepts_to_stratify=["S", "I"],
         params_to_stratify=["c", "beta", "m"])
Select concepts and parameters to preserve

Example: Stratification on a SIR model while selecting certain concepts and parameters to preserve

from mira.examples.sir import sir_petrinet as sir 
from mira.metamodel.ops import stratify

key = "vaccination_status"
strata = ["unvaccinated", "vaccinated"]
sir = stratify(sir, key, strata, concepts_to_preserve=["S", "I"],
         params_to_preserve=["c", "beta", "m"])
Rename concepts and parameters to include strata name

Example: Stratification on a SIR model while renaming specific compartment and parameters to include strata name

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.ops import stratify

key = "vaccination_status"
strata = ["unvaccinated", "vaccinated"]
sir = stratify(sir, key, strata, concepts_to_stratify=["S", "I"],
         params_to_stratify=["c", "beta", "m"],
         param_renaming_uses_strata_names=True,
         modify_names=True)
Add transition structure between strata
Stratify a model with no transition network structure

An example where we wouldn’t want any structure is if we were to stratify the model by age. This is because for the purpose of modeling, people do not age.

Example: Stratifying a SIR model by age with no transitions between strata

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.ops import stratify

key = "age"
strata = ["under50", "50+"]
sir = stratify(sir, key, strata, structure=[])
Stratify a model with some transition network structure

An example where we would want to specify some structure but not assume complete transition network structure is if we were to stratify a model based on vaccination status. This is because people can transition from being unvaccinated to vaccinated; however, it’s impossible once someone is vaccinated, to transition to unvaccinated.

We would pass in an iterable that contains a single tuple pair ("unvaccinated", "vaccinated") that represents people getting vaccinated in a SIR epidemiological model.

Example: Stratifying a SIR model by vaccination status with some transitions between strata

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.ops import stratify

key = "vaccination_status"
strata = ["unvaccinated", "vaccinated"]
sir = stratify(sir, key, strata, structure=[("unvaccinated", "vaccinated")])
Split control based relationships on stratification
Stratify a model while splitting control based relationships

The cartesian_control argument should be set to true for a SIR epidemiology model stratified on age. As the transition from the susceptible to the infected compartment for a certain age group is controlled by the infected compartment of other age groups.

Example: Stratifying a SIR model by age while splitting control based relationships

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.ops import stratify

key = "age"
strata = ["under50", "50+"]
sir = stratify(sir, key, strata, cartesian_control=True)
Stratify a model with no splitting of control based relationships

We would set cartesian_control to false for a SIR epidemiology model based on city, since the infected population in one city will not affect the infection of the susceptible population in another city.

Example: Stratifying a SIR model by city with no splitting of control based relationships

from mira.examples.sir import sir_petrinet as sir
from mira.metamodel.ops import stratify

key = "city"
strata = ["Boston", "Miami"]
sir = stratify(sir, key, strata, cartesian_control=False)

Composition

The composition method takes in a list of template models and composes them into a single template model. The list must contain at least two template models.

Example: Compose a list of two SIR based epidemiological models

from mira.examples.sir import sir, sir_2_city
from mira.metamodel.composition import compose

tm_list = [sir, sir_2_city]
composed_tm = compose(tm_list)

The composition functionality prioritizes template model attributes (parameters, initials, templates, annotation, time, model time, etc.) of the first template model in the list.

Common use-cases

Compose different variations of the same model into one comprehensive model

If we had five different template models representing variations of the base SIR epidemiological model, we can combine them using model composition.

Example: Compose five different SIR based models

from mira.metamodel import *

susceptible = Concept(name="susceptible_population",
                      identifiers={"ido": "0000514"})
hospitalized = Concept(name="hospitalized", identifiers={"ncit": "C25179"})
infected = Concept(name="infected_population", identifiers={"ido": "0000511"})
recovered = Concept(name="immune_population", identifiers={"ido": "0000592"})
dead = Concept(name="dead", identifiers={"ncit": "C28554"})
quarantined = Concept(name="quarantined", identifiers={})

infection = ControlledConversion(
    subject=susceptible,
    outcome=infected,
    controller=infected,
)
recovery = NaturalConversion(
    subject=infected,
    outcome=recovered,
)

reinfection = ControlledConversion(
    subject=recovered,
    outcome=infected,
    controller=infected,
)

to_quarantine = NaturalConversion(
    subject=susceptible,
    outcome=quarantined
)

from_quarantine = NaturalConversion(
    subject=quarantined,
    outcome=susceptible
)

dying = NaturalConversion(
    subject=infected,
    outcome=dead
)

hospitalization = NaturalConversion(
    subject=infected,
    outcome=hospitalized
)

hospitalization_to_recovery = NaturalConversion(
    subject=hospitalized,
    outcome=recovered
)

hospitalization_to_death = NaturalConversion(
    subject=hospitalized,
    outcome=dead
)

sir = TemplateModel(
    templates=[
        infection,
        recovery,
    ]
)

sir_reinfection = TemplateModel(
    templates=[
        infection,
        recovery,
        reinfection
    ]
)

sir_quarantined = TemplateModel(
    templates=[
        infection,
        to_quarantine,
        from_quarantine,
        recovery
    ]
)

sir_dying = TemplateModel(
    templates=[
        infection,
        dying,
        recovery,
    ]
)

sir_hospitalized = TemplateModel(
    templates=[
        infection,
        hospitalization,
        hospitalization_to_recovery,
        hospitalization_to_death,
    ]
)

model_list = [
    sir_reinfection,
    sir_quarantined,
    sir_dying,
    sir_hospitalized,
    sir,
]

composed_model = compose(tm_list=model_list)
Different cases of concept composition

In this section we will discuss the behavior of how concepts are composed under different circumstances.

Example: Different types of concept composition examples
from mira.metamodel import *

S1 = Concept(name="Susceptible", identifiers={"ido": "0000514"})
I1 = Concept(name="Infected", identifiers={"ido": "0000511"})
R1 = Concept(name="Recovery", identifiers={"ido": "0000592"})

S2 = Concept(name="Susceptible", identifiers={"ido": "0000513"})
I2 = Concept(name="Infected", identifiers={"ido": "0000512"})
R2 = Concept(name="Recovery", identifiers={"ido": "0000593"})

S3 = Concept(name="S", identifiers={"ido": "0000514"})
I3 = Concept(name="I", identifiers={"ido": "0000511"})
R3 = Concept(name="R", identifiers={"ido": "0000592"})

S4 = Concept(name="S")
I4 = Concept(name="I")
R4 = Concept(name="R")

model_A1 = TemplateModel(
    templates=[
        ControlledConversion(
            name='Infection',
            subject=S1,
            outcome=I1,
            controller=I1
        )
    ],
    parameters={
        'b': Parameter(name='b', value=1.0)
    }
)

model_B1 = TemplateModel(
    templates=[
        NaturalConversion(
            name='Recovery',
            subject=I1,
            outcome=R1
        )
    ],
    parameters={
        'g': Parameter(name='g', value=1.0)
    }
)

model_B2 = TemplateModel(
    templates=[
        NaturalConversion(
            name='Recovery',
            subject=I2,
            outcome=R2
        )
    ],
    parameters={
        'g': Parameter(name='g', value=1.0)
    }
)

model_B3 = TemplateModel(
    templates=[
        NaturalConversion(
            name='Recovery',
            subject=I3,
            outcome=R3
        )
    ],
    parameters={
        'g': Parameter(name='g', value=1.0)
    }
)

model_B4 = TemplateModel(
    templates=[
        NaturalConversion(
            name='Recovery',
            subject=I4,
            outcome=R4
        )
    ],
    parameters={
        'g': Parameter(name='g', value=1.0)
    }
)

model_A3 = TemplateModel(
    templates=[
        ControlledConversion(
            name='Infection',
            subject=S3,
            outcome=I3,
            controller=I3
        )
    ],
    parameters={
        'b': Parameter(name='b', value=1.0)
    }
)

model_A4 = TemplateModel(
    templates=[
        ControlledConversion(
            name='Infection',
            subject=S4,
            outcome=I4,
            controller=I4
        )
    ],
    parameters={
        'b': Parameter(name='b', value=1.0)
    }
)

# (matching name and ids) 
# composed into a single concept
model_AB11 = compose([model_A1, model_B1])

# matching names, mismatched ids
# composed into a separate concepts
model_AB12 = compose([model_A1, model_B2])

# mismatched names, matching ids
# composed into a single concept
model_AB13 = compose([model_A1, model_B3])

# matching names, no id + yes id
# composed into a single concept
model_AB34 = compose([model_A3, model_B4])

# matching names, no id + no id
# composed into a single concept
model_AB44 = compose([model_A4, model_B4])