From bfb197a60839785b7fbe6fafe73169cd40db0359 Mon Sep 17 00:00:00 2001 From: nanglo123 Date: Wed, 31 Jan 2024 21:37:16 -0500 Subject: [PATCH 1/2] Update docstrings, fix bug in expression building for stella and add end to end test for stella --- mira/sources/system_dynamics/stella.py | 52 ++++++++++++++---- mira/sources/system_dynamics/vensim.py | 2 +- tests/test_system_dynamics.py | 76 +++++++++++++++----------- 3 files changed, 84 insertions(+), 46 deletions(-) diff --git a/mira/sources/system_dynamics/stella.py b/mira/sources/system_dynamics/stella.py index a8787b4f6..72576b10e 100644 --- a/mira/sources/system_dynamics/stella.py +++ b/mira/sources/system_dynamics/stella.py @@ -42,11 +42,12 @@ def template_model_from_stella_model_file(fname) -> TemplateModel: + """Return a template model from a local Stella model file. Parameters ---------- - fname : Union[str,PosixPath] + fname : str or pathlib.Path The path to the local Stella model file Returns @@ -116,12 +117,9 @@ def extract_stella_variable_expressions(stella_model_file): operands = component.components[0][1].arguments operators = component.components[0][1].operators EXPRESSION_PER_LEVEL_MAP[component.name] = {} - extract_variables( - operands, operators, component.name - ) + extract_variables(operands, operators, component.name) elif isinstance(component, Aux): expression_map[component.name] = str(component.components[0][1]) - elif isinstance(component, Stock): try: EXPRESSION_PER_LEVEL_MAP[component.name] = {} @@ -129,9 +127,8 @@ def extract_stella_variable_expressions(stella_model_file): pass operands = component.components[0][1].flow.arguments operators = component.components[0][1].flow.operators - extract_variables( - operands, operators, component.name - ) + extract_variables(operands, operators, component.name) + # If the stock only has a reference and no operators in its expression except AttributeError: expression_map[component.name] = component.components[0][ 1 @@ -142,14 +139,26 @@ def extract_stella_variable_expressions(stella_model_file): component.components[0][1].flow.reference ] + # construct the expression for each variable once its operators and operands are mapped for var_name, expr_level_dict in EXPRESSION_PER_LEVEL_MAP.items(): expression_map[var_name] = create_expression(expr_level_dict) return expression_map def create_expression(expr_level_dict): + """When a variable's operators and operands are mapped, construct the string expression. + + Parameters + ---------- + expr_level_dict : dict[int,Any] + The mapping of level to operands and operators present in a level of an expression + Returns + ------- + : str + The string expression + """ str_expression = "" - for level, ops in expr_level_dict.items(): + for level, ops in reversed(expr_level_dict.items()): operands = ops["operands"] operators = ops["operators"] if ops.get("operators") else [] if not operators: @@ -160,9 +169,11 @@ def create_expression(expr_level_dict): level_expr = "(" for idx, operand in enumerate(operands): try: - # usually operand is a reference structure if operators[idx] != "negative": - level_expr += operand + operators[idx] + if level == len(expr_level_dict) - 1: + level_expr += operand + operators[idx] + else: + level_expr += operators[idx] + operand else: level_expr += "-" + operand except IndexError: @@ -172,7 +183,10 @@ def create_expression(expr_level_dict): level_expr = "" for idx, operand in enumerate(operands): if operators[idx] != "negative": - level_expr = operand + operators[idx] + if level == len(expr_level_dict) - 1: + level_expr = operand + operators[idx] + else: + level_expr = operators[idx] + operand else: level_expr += "-" + operand str_expression += level_expr @@ -201,6 +215,20 @@ def extract_variables(operands, operators, name): def parse_structures(operand, idx, name): + """Recursive helper method that retrieves each operand associated with a ReferenceStructure + object and operators associated with an ArithmeticStructure object. ArithmeticStructures can + contain ArithmeticStructure or ReferenceStructure Objects. + + Parameters + ---------- + operand : ReferenceStructure or ArithmeticStructure + The operand in an expression + idx : int + The level at which the operand is encountered in an expression (e.g. 5+(7-3). The + operands 7 and 3 are considered as level 1 and 5 is considered as level 5). + name : str + The name of the variable + """ if EXPRESSION_PER_LEVEL_MAP[name].get(idx) is None: EXPRESSION_PER_LEVEL_MAP[name][idx] = {} EXPRESSION_PER_LEVEL_MAP[name][idx]["operators"] = [] diff --git a/mira/sources/system_dynamics/vensim.py b/mira/sources/system_dynamics/vensim.py index b9abb433b..71e2acb94 100644 --- a/mira/sources/system_dynamics/vensim.py +++ b/mira/sources/system_dynamics/vensim.py @@ -34,7 +34,7 @@ def template_model_from_mdl_file(fname) -> TemplateModel: Parameters ---------- - fname : Union[str,PosixPath] + fname : str or pathlib.Path The path to the local Vensim file Returns diff --git a/tests/test_system_dynamics.py b/tests/test_system_dynamics.py index b684133d6..b46439159 100644 --- a/tests/test_system_dynamics.py +++ b/tests/test_system_dynamics.py @@ -55,7 +55,49 @@ def test_end_to_end_vensim(): tm = template_model_from_mdl_url(MDL_SIR_URL) model = Model(tm) amr = template_model_to_stockflow_json(tm) + end_to_end_test(model, amr) + +def test_end_to_end_stella(): + tm = template_model_from_stella_model_url(XMILE_SIR_URL) + model = Model(tm) + amr = template_model_to_stockflow_json(tm) + end_to_end_test(model, amr) + + +def sir_tm_test(tm): + assert len(tm.templates) == 2 + assert len(tm.parameters) == 3 + assert len(tm.initials) == 3 + + assert isinstance(tm.templates[0], NaturalConversion) + assert isinstance(tm.templates[1], ControlledConversion) + + assert "susceptible" in tm.initials + assert "infectious" in tm.initials + assert "recovered" in tm.initials + assert tm.initials["susceptible"].expression == SympyExprStr( + sympy.Float(1000) + ) + assert tm.initials["infectious"].expression == SympyExprStr(sympy.Float(5)) + assert tm.initials["recovered"].expression == SympyExprStr(sympy.Float(0)) + + assert "contact_infectivity" in tm.parameters + assert "duration" in tm.parameters + assert "total_population" in tm.parameters + assert tm.parameters["contact_infectivity"].value == 0.3 + assert tm.parameters["duration"].value == 5.0 + assert tm.parameters["total_population"].value == 1000 + + assert tm.templates[0].subject.name == "infectious" + assert tm.templates[0].outcome.name == "recovered" + + assert tm.templates[1].subject.name == "susceptible" + assert tm.templates[1].outcome.name == "infectious" + assert tm.templates[1].controller.name == "infectious" + + +def end_to_end_test(model, amr): assert len(model.transitions) == 2 assert len(model.variables) == 3 assert len(model.parameters) == 4 @@ -102,36 +144,4 @@ def test_end_to_end_vensim(): assert amr_semantics_ode["initials"][1]["target"] == "recovered" assert amr_semantics_ode["initials"][1]["expression"] == "0.0" assert amr_semantics_ode["initials"][2]["target"] == "susceptible" - assert amr_semantics_ode["initials"][2]["expression"] == "1000.0" - - -def sir_tm_test(tm): - assert len(tm.templates) == 2 - assert len(tm.parameters) == 3 - assert len(tm.initials) == 3 - - assert isinstance(tm.templates[0], NaturalConversion) - assert isinstance(tm.templates[1], ControlledConversion) - - assert "susceptible" in tm.initials - assert "infectious" in tm.initials - assert "recovered" in tm.initials - assert tm.initials["susceptible"].expression == SympyExprStr( - sympy.Float(1000) - ) - assert tm.initials["infectious"].expression == SympyExprStr(sympy.Float(5)) - assert tm.initials["recovered"].expression == SympyExprStr(sympy.Float(0)) - - assert "contact_infectivity" in tm.parameters - assert "duration" in tm.parameters - assert "total_population" in tm.parameters - assert tm.parameters["contact_infectivity"].value == 0.3 - assert tm.parameters["duration"].value == 5.0 - assert tm.parameters["total_population"].value == 1000 - - assert tm.templates[0].subject.name == "infectious" - assert tm.templates[0].outcome.name == "recovered" - - assert tm.templates[1].subject.name == "susceptible" - assert tm.templates[1].outcome.name == "infectious" - assert tm.templates[1].controller.name == "infectious" + assert amr_semantics_ode["initials"][2]["expression"] == "1000.0" \ No newline at end of file From 1360b9d886cd4dfeee332e45a393e44a6dcabaeb Mon Sep 17 00:00:00 2001 From: nanglo123 Date: Thu, 1 Feb 2024 15:15:42 -0500 Subject: [PATCH 2/2] Add end to end test for teacup model and test for expressions, account for case if flow has no downstream stock and remove global expression mapping for stella preprocessing --- mira/modeling/amr/stockflow.py | 2 +- mira/sources/system_dynamics/stella.py | 73 +++++++++++++-------- tests/test_system_dynamics.py | 88 +++++++++++++++++++++++--- 3 files changed, 125 insertions(+), 38 deletions(-) diff --git a/mira/modeling/amr/stockflow.py b/mira/modeling/amr/stockflow.py index 0188503b9..2e4bb3ee6 100644 --- a/mira/modeling/amr/stockflow.py +++ b/mira/modeling/amr/stockflow.py @@ -165,7 +165,7 @@ def __init__(self, model: Model): flow_dict = {"id": fid} flow_dict['name'] = flow.template.display_name flow_dict['upstream_stock'] = flow.consumed[0].concept.name - flow_dict['downstream_stock'] = flow.produced[0].concept.name + flow_dict['downstream_stock'] = flow.produced[0].concept.name if flow.produced else None if flow.template.rate_law: rate_law = flow.template.rate_law.args[0] diff --git a/mira/sources/system_dynamics/stella.py b/mira/sources/system_dynamics/stella.py index 72576b10e..99a55a081 100644 --- a/mira/sources/system_dynamics/stella.py +++ b/mira/sources/system_dynamics/stella.py @@ -38,11 +38,8 @@ template_model_from_pysd_model, ) -EXPRESSION_PER_LEVEL_MAP = {} - def template_model_from_stella_model_file(fname) -> TemplateModel: - """Return a template model from a local Stella model file. Parameters @@ -109,6 +106,8 @@ def extract_stella_variable_expressions(stella_model_file): Mapping of variable name to string variable expression """ expression_map = {} + operand_operator_per_level_var_map = {} + for component in stella_model_file.sections[0].components: if isinstance(component, ControlElement): continue @@ -116,31 +115,41 @@ def extract_stella_variable_expressions(stella_model_file): elif isinstance(component, Flow): operands = component.components[0][1].arguments operators = component.components[0][1].operators - EXPRESSION_PER_LEVEL_MAP[component.name] = {} - extract_variables(operands, operators, component.name) + operand_operator_per_level_var_map[component.name] = {} + extract_variables( + operands, + operators, + component.name, + operand_operator_per_level_var_map, + ) elif isinstance(component, Aux): expression_map[component.name] = str(component.components[0][1]) elif isinstance(component, Stock): try: - EXPRESSION_PER_LEVEL_MAP[component.name] = {} + operand_operator_per_level_var_map[component.name] = {} if component.name == "recovered": pass operands = component.components[0][1].flow.arguments operators = component.components[0][1].flow.operators - extract_variables(operands, operators, component.name) + extract_variables( + operands, + operators, + component.name, + operand_operator_per_level_var_map, + ) # If the stock only has a reference and no operators in its expression except AttributeError: expression_map[component.name] = component.components[0][ 1 ].flow.reference - EXPRESSION_PER_LEVEL_MAP[component.name] = {} - EXPRESSION_PER_LEVEL_MAP[component.name][0] = {} - EXPRESSION_PER_LEVEL_MAP[component.name][0]["operands"] = [ - component.components[0][1].flow.reference - ] + operand_operator_per_level_var_map[component.name] = {} + operand_operator_per_level_var_map[component.name][0] = {} + operand_operator_per_level_var_map[component.name][0][ + "operands" + ] = [component.components[0][1].flow.reference] # construct the expression for each variable once its operators and operands are mapped - for var_name, expr_level_dict in EXPRESSION_PER_LEVEL_MAP.items(): + for var_name, expr_level_dict in operand_operator_per_level_var_map.items(): expression_map[var_name] = create_expression(expr_level_dict) return expression_map @@ -193,7 +202,9 @@ def create_expression(expr_level_dict): return str_expression -def extract_variables(operands, operators, name): +def extract_variables( + operands, operators, name, operand_operator_per_level_var_map +): """Helper method to construct an expression for each variable in a Stella model Parameters @@ -204,17 +215,20 @@ def extract_variables(operands, operators, name): List of operators in an expression for a variable name : str Name of the variable + operand_operator_per_level_var_map : dict[str,Any] + Mapping of variable name to operators and operands associated with the level they are + encountered """ - EXPRESSION_PER_LEVEL_MAP[name][0] = {} - EXPRESSION_PER_LEVEL_MAP[name][0]["operators"] = [] - EXPRESSION_PER_LEVEL_MAP[name][0]["operands"] = [] - EXPRESSION_PER_LEVEL_MAP[name][0]["operators"].extend(operators) + operand_operator_per_level_var_map[name][0] = {} + operand_operator_per_level_var_map[name][0]["operators"] = [] + operand_operator_per_level_var_map[name][0]["operands"] = [] + operand_operator_per_level_var_map[name][0]["operators"].extend(operators) for idx, operand in enumerate(operands): - parse_structures(operand, 0, name) + parse_structures(operand, 0, name, operand_operator_per_level_var_map) -def parse_structures(operand, idx, name): +def parse_structures(operand, idx, name, operand_operator_per_level_var_map): """Recursive helper method that retrieves each operand associated with a ReferenceStructure object and operators associated with an ArithmeticStructure object. ArithmeticStructures can contain ArithmeticStructure or ReferenceStructure Objects. @@ -228,21 +242,26 @@ def parse_structures(operand, idx, name): operands 7 and 3 are considered as level 1 and 5 is considered as level 5). name : str The name of the variable + operand_operator_per_level_var_map : dict[str,Any] + Mapping of variable name to operators and operands associated with the level they are + encountered """ - if EXPRESSION_PER_LEVEL_MAP[name].get(idx) is None: - EXPRESSION_PER_LEVEL_MAP[name][idx] = {} - EXPRESSION_PER_LEVEL_MAP[name][idx]["operators"] = [] - EXPRESSION_PER_LEVEL_MAP[name][idx]["operands"] = [] + if operand_operator_per_level_var_map[name].get(idx) is None: + operand_operator_per_level_var_map[name][idx] = {} + operand_operator_per_level_var_map[name][idx]["operators"] = [] + operand_operator_per_level_var_map[name][idx]["operands"] = [] # base case if isinstance(operand, ReferenceStructure): - EXPRESSION_PER_LEVEL_MAP[name][idx]["operands"].append( + operand_operator_per_level_var_map[name][idx]["operands"].append( operand.reference ) return elif isinstance(operand, ArithmeticStructure): for struct in operand.arguments: - parse_structures(struct, idx + 1, name) - EXPRESSION_PER_LEVEL_MAP[name][idx + 1]["operators"].extend( + parse_structures( + struct, idx + 1, name, operand_operator_per_level_var_map + ) + operand_operator_per_level_var_map[name][idx + 1]["operators"].extend( operand.operators ) diff --git a/tests/test_system_dynamics.py b/tests/test_system_dynamics.py index b46439159..b98ab5095 100644 --- a/tests/test_system_dynamics.py +++ b/tests/test_system_dynamics.py @@ -8,13 +8,17 @@ from mira.modeling.amr.stockflow import template_model_to_stockflow_json from mira.metamodel import * from mira.modeling import Model +from mira.metamodel.utils import safe_parse_expr MDL_SIR_URL = "https://raw.githubusercontent.com/SDXorg/test-models/master/samples/SIR/SIR.mdl" -XMILE_SIR_URL = "https://raw.githubusercontent.com/SDXorg/test-models/master/samples/SIR/SIR.xmile" MDL_LOTKA_URL = ( "https://raw.githubusercontent.com/SDXorg/test-models/master/samples/Lotka_" "Volterra/Lotka_Volterra.mdl" ) +MDL_TEA_URL = "https://raw.githubusercontent.com/SDXorg/test-models/master/samples/teacup/teacup.mdl" + +XMILE_SIR_URL = "https://raw.githubusercontent.com/SDXorg/test-models/master/samples/SIR/SIR.xmile" +XMILE_TEA_URL = "https://raw.githubusercontent.com/SDXorg/test-models/master/samples/teacup/teacup.xmile" HERE = Path(__file__).parent MDL_SIR_PATH = HERE / "SIR.mdl" @@ -51,18 +55,32 @@ def test_stella_url(): sir_tm_test(tm) -def test_end_to_end_vensim(): +def test_end_to_end_sir_vensim(): tm = template_model_from_mdl_url(MDL_SIR_URL) model = Model(tm) amr = template_model_to_stockflow_json(tm) - end_to_end_test(model, amr) + sir_end_to_end_test(model, amr) -def test_end_to_end_stella(): +def test_end_to_end_sir_stella(): tm = template_model_from_stella_model_url(XMILE_SIR_URL) model = Model(tm) amr = template_model_to_stockflow_json(tm) - end_to_end_test(model, amr) + sir_end_to_end_test(model, amr) + + +def test_end_to_end_tea_vensim(): + tm = template_model_from_mdl_url(MDL_TEA_URL) + model = Model(tm) + amr = template_model_to_stockflow_json(tm) + tea_end_to_end_test(model, amr) + + +def test_end_to_end_tea_stella(): + tm = template_model_from_stella_model_url(XMILE_TEA_URL) + model = Model(tm) + amr = template_model_to_stockflow_json(tm) + tea_end_to_end_test(model, amr) def sir_tm_test(tm): @@ -97,10 +115,10 @@ def sir_tm_test(tm): assert tm.templates[1].controller.name == "infectious" -def end_to_end_test(model, amr): +def sir_end_to_end_test(model, amr): assert len(model.transitions) == 2 assert len(model.variables) == 3 - assert len(model.parameters) == 4 + assert len(model.parameters) - 1 == 3 assert "infectious" in model.variables assert "recovered" in model.variables assert "susceptible" in model.variables @@ -124,6 +142,15 @@ def end_to_end_test(model, amr): assert amr_model["flows"][1]["downstream_stock"] == "infectious" assert amr_model["flows"][1]["name"] == "succumbing" + assert safe_parse_expr( + amr_model["flows"][0]["rate_expression"] + ) == safe_parse_expr("infectious/duration") + assert safe_parse_expr( + amr_model["flows"][1]["rate_expression"] + ) == safe_parse_expr( + "infectious*susceptible*contact_infectivity/total_population" + ) + assert amr_model["stocks"][0]["name"] == "infectious" assert amr_model["stocks"][1]["name"] == "recovered" assert amr_model["stocks"][2]["name"] == "susceptible" @@ -140,8 +167,49 @@ def end_to_end_test(model, amr): assert amr_semantics_ode["parameters"][2]["value"] == 1000.0 assert amr_semantics_ode["initials"][0]["target"] == "infectious" - assert amr_semantics_ode["initials"][0]["expression"] == "5.0" + assert float(amr_semantics_ode["initials"][0]["expression"]) == 5.0 assert amr_semantics_ode["initials"][1]["target"] == "recovered" - assert amr_semantics_ode["initials"][1]["expression"] == "0.0" + assert float(amr_semantics_ode["initials"][1]["expression"]) == 0.0 assert amr_semantics_ode["initials"][2]["target"] == "susceptible" - assert amr_semantics_ode["initials"][2]["expression"] == "1000.0" \ No newline at end of file + assert float(amr_semantics_ode["initials"][2]["expression"]) == 1000.0 + + +def tea_end_to_end_test(model, amr): + assert len(model.transitions) == 1 + assert len(model.variables) == 1 + assert len(model.parameters) - 1 == 2 + assert "teacup_temperature" in model.variables + assert "characteristic_time" in model.parameters + assert "room_temperature" in model.parameters + + amr_model = amr["model"] + amr_semantics_ode = amr["semantics"]["ode"] + assert len(amr_model["flows"]) == 1 + assert len(amr_model["stocks"]) == 1 + assert len(amr_model["auxiliaries"]) == 2 + assert len(amr_model["links"]) == 3 + assert len(amr_semantics_ode["parameters"]) == 2 + assert len(amr_semantics_ode["initials"]) == 1 + + assert amr_model["flows"][0]["upstream_stock"] == "teacup_temperature" + assert amr_model["flows"][0]["downstream_stock"] is None + assert amr_model["flows"][0]["name"] == "heat_loss_to_room" + + assert safe_parse_expr( + amr_model["flows"][0]["rate_expression"] + ) == safe_parse_expr( + "(teacup_temperature - room_temperature)/characteristic_time" + ) + + assert amr_model["stocks"][0]["name"] == "teacup_temperature" + + assert amr_model["auxiliaries"][0]["name"] == "characteristic_time" + assert amr_model["auxiliaries"][1]["name"] == "room_temperature" + + assert amr_semantics_ode["parameters"][0]["id"] == "characteristic_time" + assert amr_semantics_ode["parameters"][0]["value"] == 10.0 + assert amr_semantics_ode["parameters"][1]["id"] == "room_temperature" + assert amr_semantics_ode["parameters"][1]["value"] == 70.0 + assert amr_semantics_ode["initials"][0]["target"] == "teacup_temperature" + assert float(amr_semantics_ode["initials"][0]["expression"]) == 180.0 +