diff --git a/docs/source/sim-explorer.pptx b/docs/source/sim-explorer.pptx index 3969175..ed735a6 100644 Binary files a/docs/source/sim-explorer.pptx and b/docs/source/sim-explorer.pptx differ diff --git a/ruff.toml b/ruff.toml index 1e782b5..d0e7b08 100644 --- a/ruff.toml +++ b/ruff.toml @@ -47,8 +47,6 @@ ignore = [ # "N816", # Variable in global scope should not be mixedCase (uncomment if you want to allow mixedCase variable names in global scope) # Ruff lint rules considered as too strict and hence ignored - "ANN101", # Missing type annotation for `self` argument in instance methods (NOTE: also listed as deprecated by Ruff) - "ANN102", # Missing type annotation for `cls` argument in class methods (NOTE: also listed as deprecated by Ruff) "FIX002", # Line contains TODO, consider resolving the issue "TD003", # Missing issue link on the line following a TODO "S101", # Use of assert detected diff --git a/src/sim_explorer/assertion.py b/src/sim_explorer/assertion.py index 3b23f16..ffec89a 100644 --- a/src/sim_explorer/assertion.py +++ b/src/sim_explorer/assertion.py @@ -1,5 +1,3 @@ -# type: ignore - import ast from typing import Any, Callable, Iterable, Iterator @@ -69,7 +67,7 @@ def info(self, sym: str, typ: str = "instance") -> str | int: elif typ == "variable": # get the generic variable name return var elif typ == "length": # get the number of elements - return len(self._cases_variables[var]["variables"]) + return len(self._cases_variables[var]["refs"]) elif typ == "model": # get the basic (FMU) model return self._cases_variables[var]["model"] else: @@ -250,8 +248,8 @@ def register_vars(self, variables: dict): for key, info in variables.items(): for inst in info["instances"]: if len(info["instances"]) == 1: # the instance is unique - self.symbol(key, len(info["variables"])) # we allow to use the 'short name' if unique - self.symbol(inst + "_" + key, len(info["variables"])) # fully qualified name can always be used + self.symbol(key, len(info["names"])) # we allow to use the 'short name' if unique + self.symbol(inst + "_" + key, len(info["names"])) # fully qualified name can always be used def make_locals(self, loc: dict): """Adapt the locals with 'allowed' functions.""" @@ -303,7 +301,7 @@ def eval_single(self, key: str, kvargs: dict | list | tuple): # print("kvargs", kvargs, self._syms[key], self.expr_get_symbols_functions(key)) return self._eval(locals()["_" + key], kvargs) - def eval_series(self, key: str, data: list[Any], ret: float | str | Callable | None = None): + def eval_series(self, key: str, data: list, ret: float | str | Callable | None = None): """Perform assertion on a (time) series. Args: @@ -336,7 +334,7 @@ def eval_series(self, key: str, data: list[Any], ret: float | str | Callable | N _temp = self._temporal[key]["type"] if ret is None else Temporal.UNDEFINED for row in data: - if not isinstance(row, Iterable): # can happen if the time itself is evaluated + if not isinstance(row, (tuple, list)): # can happen if the time itself is evaluated time = row row = [row] elif "t" not in argnames: # the independent variable is not explicitly used in the expression diff --git a/src/sim_explorer/case.py b/src/sim_explorer/case.py index a154c13..1a5c66f 100644 --- a/src/sim_explorer/case.py +++ b/src/sim_explorer/case.py @@ -1,21 +1,21 @@ from __future__ import annotations +import copy import os from collections.abc import Callable from datetime import datetime -from functools import partial from pathlib import Path -from typing import Any, Iterable, List +from typing import Any, Iterable, Sequence import matplotlib.pyplot as plt import numpy as np -from libcosimpy.CosimLogging import CosimLogLevel, log_output_level # type: ignore from sim_explorer.assertion import Assertion # type: ignore from sim_explorer.exceptions import CaseInitError from sim_explorer.json5 import Json5 from sim_explorer.models import AssertionResult, Temporal -from sim_explorer.simulator_interface import SimulatorInterface +from sim_explorer.system_interface import SystemInterface +from sim_explorer.system_interface_osp import SystemInterfaceOSP from sim_explorer.utils.misc import from_xml from sim_explorer.utils.paths import get_path, relative_path @@ -33,22 +33,6 @@ """ -def _assert(condition: bool, msg: str, crit: int = 4, typ=CaseInitError): - """Check condition and raise error is relevant with respect to condition and crit.""" - if crit == 1: - print(f"DEBUG ({condition}): {msg}") - elif crit == 2: - print("INFO ({condition}): {msg}") - else: - if condition: - return - else: - if crit == 3: - print("WARNING:", msg) - else: - raise typ(msg) from None - - class Case: """Instantiation of a Case object. Sub-cases are strored ins list 'self.subs'. @@ -93,24 +77,25 @@ def __init__( else: assert isinstance(self.parent, Case), f"Parent case expected for case {self.name}" self.special = dict(self.parent.special) - self.act_get = Case._actions_copy(self.parent.act_get) - self.act_set = Case._actions_copy(self.parent.act_set) - - for k, v in self.js.jspath("$.spec", dict, True).items(): - self.read_spec_item(k, v) - _results = self.js.jspath("$.results", list) - if _results is not None: - for _res in _results: - self.read_spec_item(_res) - self.asserts: list = [] # list of assert keys - _assert = self.js.jspath("$.assert", dict) - if _assert is not None: - for k, v in _assert.items(): - _ = self.read_assertion(k, v) - if self.name == "base": - self.special = self._ensure_specials(self.special) # must specify for base case - self.act_get = dict(sorted(self.act_get.items())) - self.act_set = dict(sorted(self.act_set.items())) + self.act_get = copy.deepcopy(self.parent.act_get) + self.act_set = copy.deepcopy(self.parent.act_set) + + if self.cases.simulator.full_simulator_available: + for k, v in self.js.jspath("$.spec", dict, True).items(): + self.read_spec_item(k, v) + _results = self.js.jspath("$.results", list) + if _results is not None: + for _res in _results: + self.read_spec_item(_res) + self.asserts: list = [] # list of assert keys + _assert = self.js.jspath("$.assert", dict) + if _assert is not None: + for k, v in _assert.items(): + _ = self.read_assertion(k, v) + if self.name == "base": + self.special = self._ensure_specials(self.special) # must specify for base case + self.act_get = dict(sorted(self.act_get.items())) + self.act_set = dict(sorted(self.act_set.items())) # self.res represents the Results object and is added when collecting results or when evaluating results def add_results_object(self, res: Results): @@ -149,48 +134,18 @@ def append(self, case: "Case"): """Append a case as sub-case to this case.""" self.subs.append(case) - def _add_action(self, typ: str, action: Callable, args: tuple, at_time: float): - """Add an action to one of the properties act_set, act_get, act_final, act_step - used for results. - We use functools.partial to return the functions with fully filled in arguments. - Compared to lambda... this allows for still accessible (testable) argument lists. - - Args: - typ (str): the action type 'get' or 'set' - action (Callable): the relevant action (manipulator/observer) function to perform - args (tuple): action arguments as tuple (instance:int, type:int, valueReferences:list[int][, values]) - at_time (float): optional time argument (not needed for all actions) + def _add_actions(self, act_type: str, at_time: float, act_list: list[Callable]): + """Add actions to one of the properties act_get, act_set. + The act_list should be prepared by the system_interface in the way it is needed there. """ - if typ == "get": + if act_type == "get": dct = self.act_get - elif typ == "set": - dct = self.act_set else: - raise AssertionError(f"Unknown typ {typ} in _add_action") - assert isinstance(at_time, (float, int)), f"Actions require a defined time as float. Found {at_time}" - if at_time in dct: - for i, act in enumerate(dct[at_time]): - if act.func.__name__ == action.__name__ and all(act.args[k] == args[k] for k in range(2)): - # the type of action, the model id and the variable type match - if isinstance(args[2], int): # single variable (used for initial set actions) - if args[2] == act.args[2]: - if typ == "set": - dct[at_time][i] = partial(action, *args) # replace - return - elif all(r in act.args[2] for r in args[2]): # refs are a subset or equal - if typ == "set": # Need to (partially) replace value(s) - values = list(act.args[3]) # copy of existing values - for k, r in enumerate(act.args[2]): # go through refs - for _k, _r in enumerate(args[2]): - if r == _r: - values[k] = args[3][_k] # replace - dct[at_time][i] = partial( - action, args[0], args[1], act.args[2], tuple(values) - ) # replace action - return # Note: get actions do not need special actions - dct[at_time].append(partial(action, *args)) - - else: # no action for this time yet - dct.update({at_time: [partial(action, *args)]}) + dct = self.act_set + + if at_time not in dct: + dct.update({at_time: []}) + dct[at_time].extend(act_list) @staticmethod def _num_elements(obj) -> int: @@ -353,7 +308,7 @@ def read_spec_item(self, key: str, value: Any | None = None): Returns ------- - self.act_*** actions through _add_action() + updated self.act_*** actions through add_actions() of the SystemInterface*** """ if key in ("startTime", "stopTime", "stepSize"): self.special.update({key: value}) # just keep these as a dictionary so far @@ -366,74 +321,23 @@ def read_spec_item(self, key: str, value: Any | None = None): if value is not None: # check also the number of supplied values if isinstance(value, (str, float, int, bool)): # make sure that there are always lists value = [value] - _assert( - sum(1 for _ in rng) == Case._num_elements(value), - f"Variable {key}: # values {value} != # vars {rng}", - ) - var_refs = [] - var_vals = [] - for i, r in enumerate(rng): - var_refs.append(cvar_info["variables"][r]) - if value is not None: - var_vals.append(value[i]) + # assert sum(1 for _ in rng) == Case._num_elements(value), f"Variable {key}: # values {value} != # vars {rng}", + assert len(rng) == Case._num_elements(value), f"Variable {key}: # values {value} != # vars {rng}" + varnames = tuple([cvar_info["names"][r] for r in rng]) # print(f"CASE.read_spec, {key}@{at_time_arg}({at_time_type}):{value}[{rng}], alias={cvar_info}") - if at_time_type in ("get", "step"): # get actions - for inst in cvar_info["instances"]: # ask simulator to provide function to set variables: - _inst = self.cases.simulator.component_id_from_name(inst) - if not self.cases.simulator.allowed_action("get", _inst, tuple(var_refs), 0): - raise AssertionError(self.cases.simulator.message) from None - elif at_time_type == "get" or at_time_arg == -1: # normal get or step without time spec - self._add_action( - "get", - self.cases.simulator.get_variable_value, - (_inst, cvar_info["type"], tuple(var_refs)), - (at_time_arg if at_time_arg <= 0 else at_time_arg * self.cases.timefac), - ) - else: # step actions with specified interval - for time in np.arange( - start=at_time_arg, - stop=self.special["stopTime"], - step=at_time_arg, - ): - self._add_action( - time, - self.cases.simulator.get_variable_value, - (_inst, cvar_info["type"], tuple(var_refs)), - at_time_arg * self.cases.timefac, - ) - else: # set actions - assert value is not None, f"Variable {key}: Value needed for 'set' actions." - assert at_time_type in ("set"), f"Unknown @time type {at_time_type} for case '{self.name}'" - if at_time_arg <= self.special["startTime"]: # False: #?? set_initial() does so far not work??# - # SimulatorInterface.default_initial(cvar_info["causality"], cvar_info["variability"]) < 3: - assert at_time_arg <= self.special["startTime"], f"Initial settings at time {at_time_arg}?" - for inst in cvar_info["instances"]: # ask simulator to provide function to set variables: - _inst = self.cases.simulator.component_id_from_name(inst) - if not self.cases.simulator.allowed_action("set", _inst, tuple(var_refs), 0): - raise AssertionError(self.cases.simulator.message) from None - for ref, val in zip(var_refs, var_vals, strict=False): - self._add_action( - at_time_type, - self.cases.simulator.set_initial, - (_inst, cvar_info["type"], ref, val), - at_time_arg * self.cases.timefac, - ) - else: - for inst in cvar_info["instances"]: # ask simulator to provide function to set variables: - _inst = self.cases.simulator.component_id_from_name(inst) - if not self.cases.simulator.allowed_action("set", _inst, tuple(var_refs), at_time_arg): - raise AssertionError(self.cases.simulator.message) from None - self._add_action( - at_time_type, - self.cases.simulator.set_variable_value, - ( - _inst, - cvar_info["type"], - tuple(var_refs), - tuple(var_vals), - ), - at_time_arg * self.cases.timefac, - ) + if not self.cases.simulator.allowed_action(at_time_type, cvar_info["instances"][0], varnames, at_time_arg): + raise AssertionError(self.cases.simulator.message) from None + else: # action allowed. Ask simulator to add actions appropriately + self.cases.simulator.add_actions( + self.act_get if at_time_type in ("get", "step") else self.act_set, + at_time_type, + key, + cvar_info, + value, + at_time_arg if at_time_arg <= 0 else at_time_arg * self.cases.timefac, + self.special["stopTime"], + tuple(rng), + ) def list_cases(self, as_name: bool = True, flat: bool = False) -> list[str] | list[Case]: """List this case and all sub-cases recursively, as name or case objects.""" @@ -454,8 +358,8 @@ def _ensure_specials(self, special: dict[str, Any]) -> dict[str, Any]: """ def get_from_config(element: str, default: float | None = None): - if isinstance(self.cases.simulator.sysconfig, Path): - info = from_xml(self.cases.simulator.sysconfig, sub=None, xpath=".//{*}" + element) + if isinstance(self.cases.simulator.structure_file, Path): + info = from_xml(self.cases.simulator.structure_file, sub=None).findall(".//{*}" + element) if not len(info): return default txt = info[0].text @@ -480,37 +384,35 @@ def get_from_config(element: str, default: float | None = None): def run(self, dump: str | None = ""): """Set up case and run it. + All get action are recorded in results and get actions always concern whole case variables. + It is difficult to report initial settings. Therefore all start values are collected, + changed with initial settings (settings before the main simulation loop) and reported. + Args: dump (str): Optionally save the results as json file. None: do not save, '': use default file name, str (with or without '.js5'): save with that file name """ - def do_actions(_t: float, _a, _iter, time: int, record: bool = True): - while time >= _t: # issue the _a - actions - if len(_a): - if record: - for a in _a: - self.res.add( - time / self.cases.timefac, - a.args[0], - a.args[1], - a.args[2], - a(), - ) - else: # do not record - for a in _a: - a() + def do_actions(_t: float, actions, _iter, time: int | float): + while time >= _t: # issue the actions - actions + if len(actions): + for _act in actions: + res = self.cases.simulator.do_action(time, _act, self.cases.variables[_act[0]]["type"]) + if len(_act) == 3: # get action. Report + self.res.add(time / self.cases.timefac, _act[1], _act[0], res) try: - _t, _a = next(_iter) + _t, actions = next(_iter) except StopIteration: - _t, _a = 10 * tstop, [] - return (_t, _a) + _t, actions = 10 * tstop, [] + return (_t, actions) + self.cases.simulator.init_simulator() # Note: final actions are included as _get at stopTime tstart: int = int(self.special["startTime"] * self.cases.timefac) time = tstart tstop: int = int(self.special["stopTime"] * self.cases.timefac) tstep: int = int(self.special["stepSize"] * self.cases.timefac) + starts = self.cases.get_starts() # start value of all case variables (where defined) set_iter = self.act_set.items().__iter__() # iterator over set actions => time, action_list try: @@ -518,7 +420,7 @@ def do_actions(_t: float, _a, _iter, time: int, record: bool = True): except StopIteration: t_set, a_set = (float("inf"), []) # satisfy linter get_iter = self.act_get.items().__iter__() # iterator over get actions => time, action_list - act_step = None + act_step = [] self.add_results_object(Results(self)) while True: @@ -527,52 +429,38 @@ def do_actions(_t: float, _a, _iter, time: int, record: bool = True): except StopIteration: t_get, a_get = (tstop + 1, []) if t_get < 0: # negative time indicates 'always' - act_step = a_get + for a in a_get: + act_step.append((*a, self.cases.simulator.action_step(a, self.cases.variables[a[0]]["type"]))) else: break - for a in a_set: # since there is no hook to get initial values we report it this way - self.res.add(tstart, *a.args) + for cvar, _comp, refs, values in a_set: # since there is no hook to get initial values we keep track + refs, values = self.cases.simulator.update_refs_values( + self.cases.variables[cvar]["refs"], self.cases.variables[cvar]["refs"], starts[cvar], refs, values + ) + starts[cvar] = values + + for v, s in starts.items(): # report the start values + for c in self.cases.variables[cvar]["instances"]: + self.res.add(tstart, c, v, s if len(s) > 1 else s[0]) while True: # main simulation loop - t_set, a_set = do_actions(t_set, a_set, set_iter, time, record=False) + t_set, a_set = do_actions(t_set, a_set, set_iter, time) time += tstep if time > tstop: break - self.cases.simulator.simulator.simulate_until(time) + if not self.cases.simulator.run_until(time): + break t_get, a_get = do_actions(t_get, a_get, get_iter, time) # issue the current get actions - if act_step is not None: # there are step-always actions - for a in act_step: - self.res.add(time / self.cases.timefac, a.args[0], a.args[1], a.args[2], a()) + if len(act_step): # there are step-always actions + for cvar, comp, _refs, a in act_step: + self.res.add(time / self.cases.timefac, comp, cvar, a()) - self.cases.simulator.reset() if dump is not None: self.res.save(dump) - @staticmethod - def _actions_copy(actions: dict) -> dict: - """Copy the dict of actions to a new dict, - which can be changed without changing the original dict. - Note: deepcopy cannot be used here since actions contain pointer objects. - """ - res = {} - for t, t_actions in actions.items(): - action_list = [] - for action in t_actions: - action_list.append(partial(action.func, *action.args)) - res.update({t: action_list}) - return res - - @staticmethod - def str_act(action: Callable): - """Prepare a human readable view of the action.""" - txt = f"{action.func.__name__}(inst={action.args[0]}, type={action.args[1]}, ref={action.args[2]}" # type: ignore - if len(action.args) > 3: # type: ignore - txt += f", val={action.args[3]}" # type: ignore - return txt - class Cases: """Global book-keeping of all cases defined for a system model. @@ -584,8 +472,8 @@ class Cases: Args: spec (Path): file name for cases specification - simulator (SimulatorInterface)=None: Optional (pre-instantiated) SimulatorInterface object. - If that is None, the spec shall contain a modelFile to be used to instantiate the simulator. + simulator_type (SystemInterface)=SystemInterfaceOSP: Optional possibility to choose system simulator details + Default is OSP (libcosimpy), but when only results are read the basic SystemInterface is sufficient. """ __slots__ = ( @@ -597,39 +485,31 @@ class Cases: "variables", "base", "assertion", - "_comp_refs_to_case_var_cache", "results_print_type", ) - assertion_results: List[AssertionResult] = [] + assertion_results: list[AssertionResult] = [] - def __init__(self, spec: str | Path, simulator: SimulatorInterface | None = None): + def __init__(self, spec: str | Path): self.file = Path(spec) # everything relative to the folder of this file! assert self.file.exists(), f"Cases spec file {spec} not found" self.js = Json5(spec) - log_level = CosimLogLevel[self.js.jspath("$.header.logLevel") or "FATAL"] - if simulator is None: - modelfile = self.js.jspath("$.header.modelFile", str) or "OspSystemStructure.xml" - path = self.file.parent / modelfile - assert path.exists(), f"OSP system structure file {path} not found" - try: - self.simulator = SimulatorInterface( - system=path, - name=self.js.jspath("$.header.name", str) or "", - description=self.js.jspath("$.header.description", str) or "", - log_level=log_level, - ) - except Exception as err: - raise AssertionError(f"'modelFile' needed from spec: {err}") from err - else: - self.simulator = simulator # SimulatorInterface( simulator = simulator) - log_output_level(log_level) + name = (self.js.jspath("$.header.name", str) or "",) + description = (self.js.jspath("$.header.description", str) or "",) + log_level = self.js.jspath("$.header.logLevel") or "fatal" + modelfile = self.js.jspath("$.header.modelFile", str) or "OspSystemStructure.xml" + simulator = self.js.jspath("$.header.simulator", str) or "" + path = self.file.parent / modelfile + assert path.exists(), f"System structure file {path} not found" + if simulator == "": # without ability to perform simulatiosn + self.simulator = SystemInterface(path, name, description, log_level) # type: ignore + elif simulator == "OSP": + self.simulator = SystemInterfaceOSP(path, name, description, log_level) # type: ignore self.timefac = self._get_time_unit() * 1e9 # internally OSP uses pico-seconds as integer! # read the 'variables' section and generate dict { alias : { (instances), (variables)}}: self.variables = self.get_case_variables() self.assertion = Assertion() self.assertion.register_vars(self.variables) # register variables as symbols - self._comp_refs_to_case_var_cache: dict = dict() # cache used by comp_refs_to_case_var() self.read_cases() def get_case_variables(self) -> dict[str, dict]: @@ -637,14 +517,18 @@ def get_case_variables(self) -> dict[str, dict]: { c_var_name : {'model':model ID, 'instances': tuple of instance names, - 'variables': tuple of ValueReference, - 'type':CosimVariableType, - 'causality':CosimVariableCausality, - 'variability': CosimVariableVariability}. + 'names': tuple of variable names, + 'refs': tuple of ValueReferences + 'type': python type, + 'causality': causality (str), + 'variability': variability (str), + 'initial': initial (str) + 'start': tuple of start values}. Optionally a description of the alias variable may be provided (and added to the dictionary). """ variables = {} + model_vars = {} # cache of variables of models for k, v in self.js.jspath("$.header.variables", dict, True).items(): if not isinstance(v, list): raise CaseInitError(f"List of 'component(s)' and 'variable(s)' expected. Found {v}") from None @@ -657,47 +541,60 @@ def get_case_variables(self) -> dict[str, dict]: model, comp = self.simulator.match_components(v[0]) assert len(comp) > 0, f"No component model instances '{v[0]}' found for alias variable '{k}'" assert isinstance(v[1], str), f"Second argument of variable sped: Variable name(s)! Found {v[1]}" - _vars = self.simulator.match_variables(comp[0], v[1]) # tuple of matching var refs + _vars = self.simulator.match_variables(comp[0], v[1]) # tuple of matching variables (name,ref) + _varnames = tuple([n for n, _ in _vars]) + _varrefs = tuple([r for _, r in _vars]) + if model not in model_vars: # ensure that model is included in the cache + model_vars.update({model: self.simulator.variables(comp[0])}) + prototype = model_vars[model][_varnames[0]] var: dict = { "model": model, "instances": comp, - "variables": _vars, # variables from same model! + "names": _varnames, # variable names from FMU + "refs": _varrefs, } - assert len(var["variables"]) > 0, f"No matching variables found for alias {k}:{v}, component '{comp}'" + assert len(var["names"]), f"No matching variables found for alias {k}:{v}, component '{comp}'" if len(v) > 2: var.update({"description": v[2]}) # We add also the more detailed variable info from the simulator (the FMU) # The type, causality and variability shall be equal for all variables. - # The 'reference' element is the same as 'variables'. - # next( iter( ...)) is used to get the first dict value - var0 = next(iter(self.simulator.get_variables(model, _vars[0]).values())) # prototype - for i in range(1, len(var["variables"])): - var_i = next(iter(self.simulator.get_variables(model, _vars[i]).values())) + for n in _varnames: for test in ["type", "causality", "variability"]: - _assert( - var_i[test] == var0[test], - f"Variable with ref {var['variables'][i]} not same {test} as {var0} in model {model}", + assert model_vars[model][n][test] == prototype[test], ( + f"Model {model} variable {n} != {test} as {prototype}" ) + starts = [] + for v, info in self.simulator.models[model]["variables"].items(): + if v in _varnames and "start" in info: + starts.append(info["type"](info["start"])) var.update( { - "type": var0["type"], - "causality": var0["causality"], - "variability": var0["variability"], + "type": prototype["type"], + "causality": prototype["causality"], + "variability": prototype["variability"], + "initial": prototype.get("initial", ""), + "start": tuple(starts), } ) variables.update({k: var}) return variables - # def get_alias_from_spec(self, modelname: str, instance: str, ref: Union[int, str]) -> str: - # """Get a variable alias from its detailed specification (modelname, instance, ref).""" - # for alias, var in self.variables.items(): - # print("GET_ALIAS", alias, var) - # if var["model"].get("modelName") == modelname: - # if instance in var["instances"]: - # for v in var["variables"]: - # if v.get("valueReference", "-1") == str(ref) or v.get("name", "") == ref: - # return alias - # + def case_variable(self, component: str, variables: str | tuple): + """Identify the case variable (as defined in the spec) from the component instance and fmu variable names.""" + if isinstance(variables, (tuple, list)) and len(variables) == 1: + variables = variables[0] + for var, info in self.variables.items(): + if component in info["instances"]: + rng = [] + for i, name in enumerate(info["names"]): + if name in variables: + rng.append(i) + if len(rng) == len(variables): # perfect match + return (var, ()) + elif len(rng) > 0: + return (var, tuple(rng)) + raise KeyError(f"Undefined case variable with component {component}, variables {variables}") from None + def _get_time_unit(self) -> float: """Find system time unit from the spec and return as seconds. If the entry is not found, 1 second is assumed. @@ -759,22 +656,7 @@ def case_by_name(self, name: str) -> Case | None: return found return None - def case_var_by_ref(self, comp: int | str, ref: int | tuple[int, ...]) -> tuple[str, tuple]: - """Get the case variable name related to the component model `comp` and the reference `ref` - Returns a tuple of case variable name and an index (if composit variable). - """ - component = self.simulator.component_name_from_id(comp) if isinstance(comp, int) else comp - refs = (ref,) if isinstance(ref, int) else ref - - for var, info in self.variables.items(): - if component in info["instances"] and all(r in info["variables"] for r in refs): - if len(refs) == len(info["variables"]): # the whole variable is addressed - return (var, ()) - else: - return (var, tuple([info["variables"].index(r) for r in refs])) - return ("", ()) - - def disect_variable(self, key: str, err_level: int = 2) -> tuple[str, dict, list | range]: + def disect_variable(self, key: str, err_level: int = 2) -> tuple[str, dict, Sequence]: """Extract the variable name, definition and explicit variable range, if relevant (multi-valued variables, where only some elements are addressed). ToDo: handle multi-dimensional arrays (tables, ...). @@ -786,7 +668,7 @@ def disect_variable(self, key: str, err_level: int = 2) -> tuple[str, dict, list ------- 1. The variable name as defined in the 'variables' section of the spec 2. The variable definition, which the name refers to - 3. An iterator over indices of the variable, i.e. the range + 3. An Sequence over indices of the variable, i.e. the range """ def handle_error(msg: str, err: Exception | None, level: int): @@ -807,7 +689,7 @@ def handle_error(msg: str, err: Exception | None, level: int): err_level, ) - cvar_len = len(cvar_info["variables"]) # len of the tuple of refs + cvar_len = len(cvar_info["names"]) # len of the tuple of refs if len(r): # range among several variables r = r.rstrip("]").strip() # string version of a non-trivial range parts_comma = r.split(",") @@ -837,10 +719,7 @@ def handle_error(msg: str, err: Exception | None, level: int): ) rng.append(idx) else: - _assert( - len(parts_ellipses) == 2, - f"RangeError: Exactly two indices expected in {p} of {pre}", - ) + assert len(parts_ellipses) == 2, f"RangeError: Exactly two indices expected in {p} of {pre}" parts_ellipses[1] = parts_ellipses[1].lstrip(".") # facilitates the option to use '...' or '..' try: if len(parts_ellipses[0]) == 0: @@ -867,13 +746,21 @@ def handle_error(msg: str, err: Exception | None, level: int): rng = range(cvar_len) return (pre, cvar_info, rng) + def get_starts(self): + """Get a copy of the start values (as advised by FMU) as dict {case-var : (start-values), }.""" + starts: dict = {} + for v, info in self.variables.items(): + if "start" in info and len(info["start"]): + starts.update({v: list(info["start"])}) + return starts + def info(self, case: Case | None = None, level: int = 0) -> str: - """Show main infromation and the cases structure as string.""" + """Show main information and the cases structure as string.""" txt = "" if case is None: case = self.base txt += "Cases " - txt += f"{self.js.jspath('$.header.name',str) or 'noName'}. " + txt += f"{self.js.jspath('$.header.name', str) or 'noName'}. " txt += f"{(self.js.jspath('$.header.description', str) or '')}\n" modelfile = self.js.jspath("$.header.modelFile", str) if modelfile is not None: @@ -888,23 +775,6 @@ def info(self, case: Case | None = None, level: int = 0) -> str: raise ValueError(f"The argument 'case' shall be a Case object or None. Type {type(case)} found.") return txt - def comp_refs_to_case_var(self, comp: int, refs: tuple[int, ...]): - """Get the translation of the component id `comp` + references `refs` - to the variable names used in the cases file. - To speed up the process the cache dict _comp_refs_to_case_var_cache is used. - """ - try: - component, var = self._comp_refs_to_case_var_cache[comp][refs] - except Exception: - component = self.simulator.component_name_from_id(comp) - var, rng = self.case_var_by_ref(component, refs) - if len(rng): # elements of a composit variable - var += f"{list(rng)}" - if comp not in self._comp_refs_to_case_var_cache: - self._comp_refs_to_case_var_cache.update({comp: {}}) - self._comp_refs_to_case_var_cache[comp].update({refs: (component, var)}) - return component, var - def run_case(self, name: str | Case, dump: str | None = "", run_subs: bool = False, run_assertions: bool = False): """Initiate case run. If done from here, the case name can be chosen. If run_subs = True, also the sub-cases are run. @@ -967,7 +837,7 @@ def _init_from_existing(self, file: str | Path): except ValueError: raise CaseInitError(f"Cases {Path(case)} instantiation error") from ValueError self.case: Case | None = cases.case_by_name(name=self.res.jspath(path="$.header.case", typ=str, errorMsg=True)) - assert isinstance(self.case, Case), f"Case {self.res.jspath( '$.header.case', str, True)} not found" + assert isinstance(self.case, Case), f"Case {self.res.jspath('$.header.case', str, True)} not found" assert isinstance(self.case.cases, Cases), "Cases object not defined" self._header_transform(False) self.case.add_results_object(self) # make Results object known to self.case @@ -1038,25 +908,18 @@ def _header_transform(self, tostring: bool = True): get_path(res.jspath("$.header.file", str, True), self.file.parent), ) - def add(self, time: float, comp: int, typ: int, refs: int | list[int], values: tuple): + def add(self, time: float, comp: str, cvar: str, values: Sequence | int | float | bool | str): """Add the results of a get action to the results dict for the case. Args: time (float): the time of the results - component (int): The index of the component - typ (int): The data type of the variable as enumeration int - ref (list): The variable reference(s) linked to this variable definition - values (tuple): the values of the variable + comp (str): the component name + cvar (str): the case variable name + values (PyVal, tuple): the value(s) to record """ - if isinstance(refs, int): - refs = [refs] - values = (values,) - compname, varname = self.case.cases.comp_refs_to_case_var(comp, tuple(refs)) # type: ignore [union-attr] - # print(f"ADD@{time}: {compname}, {varname} = {values}") - if len(values) == 1: - self.res.update("$[" + str(time) + "]" + compname, {varname: values[0]}) - else: - self.res.update("$[" + str(time) + "]" + compname, {varname: values}) + # print(f"Update ({time}): {comp}: {cvar} : {values}") + _values = values[0] if isinstance(values, Sequence) and len(values) == 1 else values + self.res.update("$[" + str(time) + "]" + comp, {cvar: _values}) def save(self, jsfile: str | Path = ""): """Dump the results dict to a json5 file. @@ -1121,12 +984,12 @@ def retrieve(self, comp_var: Iterable) -> list: """Retrieve from results js5-dict the variables and return (times, values). Args: - comp_var (Iterable): iterable of (, [, element]) + comp_var (Iterator): iterator over (, [, element]) Alternatively, the jspath syntax .[[element]] can be used as comp_var. - Time is not explicitly including in comp_var - A record is only included if all variable are found for a given time + Time is not explicitly included in comp_var + A record is only included if all variables are found for a given time Returns: - Data table (list of lists), time and one column per variable + Data table (list of lists): time and one column per variable """ data = [] _comp_var = [] @@ -1159,11 +1022,11 @@ def retrieve(self, comp_var: Iterable) -> list: data.append(record) return data - def plot_time_series(self, comp_var: Iterable, title: str = ""): + def plot_time_series(self, comp_var: Sequence, title: str = ""): """Extract the provided alias variables and plot the data found in the same plot. Args: - comp_var (Iterable): Iterable of (,) tuples (as used in retrieve) + comp_var (Sequence): Sequence of (,) tuples (as used in retrieve) Alternatively, the jspath syntax . is also accepted title (str): optional title of the plot """ diff --git a/src/sim_explorer/cli/sim_explorer.py b/src/sim_explorer/cli/sim_explorer.py index ee9a407..0b0b4e8 100644 --- a/src/sim_explorer/cli/sim_explorer.py +++ b/src/sim_explorer/cli/sim_explorer.py @@ -146,7 +146,7 @@ def main() -> None: logger.error(f"Instantiation of {args.cases} not successfull") return - log_msg_stub: str = f"Start sim-explorer.py with following arguments:\n" f"\t cases: \t{cases}\n" + log_msg_stub: str = f"Start sim-explorer.py with following arguments:\n\t cases: \t{cases}\n" case: Case | None = None diff --git a/src/sim_explorer/json5.py b/src/sim_explorer/json5.py index f1a6c07..6b9721a 100644 --- a/src/sim_explorer/json5.py +++ b/src/sim_explorer/json5.py @@ -97,9 +97,9 @@ def _msg(self, pre: str, num: int = 50, pos: int | None = None) -> str: pos = self.pos try: line, p = self._get_line_number(pos) - return f"Json5 read error at {line}({p}): {pre}: {self.js5[pos : pos+num]}" + return f"Json5 read error at {line}({p}): {pre}: {self.js5[pos : pos + num]}" except Json5Error as err: # can happen when self.lines does not yet exist - return f"Json5 read error at {pos}: {pre}: {self.js5[pos : pos+num]}: {err}" + return f"Json5 read error at {pos}: {pre}: {self.js5[pos : pos + num]}: {err}" def _lines(self): """Map start positions of lines and replace all newline CR-LF combinations with single newline (LF).""" @@ -260,7 +260,7 @@ def _re(txt: str): _js5 += js5[pos : pos + s1.start()] pos += pos + s1.start() s2 = c2.search(js5[pos:]) - assert s2 is not None, f"No end of comment found for comment starting with '{js5[pos:pos+50]}'" + assert s2 is not None, f"No end of comment found for comment starting with '{js5[pos : pos + 50]}'" comments.update({pos + s2.start(): js5[pos : pos + s2.start()]}) for p in range(pos, pos + s2.end()): if js5[p] not in ("\r", "\n"): @@ -362,7 +362,7 @@ def _key(self) -> str: m = re.search(r":", self.js5[self.pos :]) assert m is not None, self._msg(f"Quoted key {k} found, but no ':'") assert not len(self.js5[self.pos : self.pos + m.start()].strip()), self._msg( - f"Additional text '{self.js5[self.pos : self.pos+m.start()].strip()}' after key '{k}'" + f"Additional text '{self.js5[self.pos : self.pos + m.start()].strip()}' after key '{k}'" ) else: m = re.search(r"[:\}]", self.js5[self.pos :]) @@ -395,7 +395,7 @@ def _value(self): v = self._object() if m.group() == "{" else self._list() m = re.search(r"[,\}\]]", self.js5[self.pos :]) cr, cc = self._get_line_number(self.pos) - assert m is not None, self._msg(f"End of value or end of object/list '{str(v)[:50]+'..'}' expected") + assert m is not None, self._msg(f"End of value or end of object/list '{str(v)[:50] + '..'}' expected") elif m.group() in ( "]", "}", @@ -404,7 +404,7 @@ def _value(self): v = self.js5[self.pos : self.pos + m.start()].strip() if q2 < 0 else self.js5[q1 + 1 : q2 - 1] else: raise Json5Error( - f"Unhandled situation. Quoted: ({q1-self.pos},{q2-self.pos}), search: {m}. From pos: {self.js5[self.pos : ]}" + f"Unhandled situation. Quoted: ({q1 - self.pos},{q2 - self.pos}), search: {m}. From pos: {self.js5[self.pos :]}" ) # save_pos = self.pos self.pos += ( @@ -421,6 +421,8 @@ def _value(self): elif isinstance(v, str) and not len(v): # might be empty due to trailing ',' return "" + if q2 >= 0: # explicitly quoted values are treated as strings! + return str(v) try: return int(v) # type: ignore except Exception: diff --git a/src/sim_explorer/simulator_interface.py b/src/sim_explorer/simulator_interface.py deleted file mode 100644 index 8b87827..0000000 --- a/src/sim_explorer/simulator_interface.py +++ /dev/null @@ -1,595 +0,0 @@ -# pyright: reportMissingImports=false, reportGeneralTypeIssues=false -import xml.etree.ElementTree as ET # noqa: N817 -from enum import Enum -from pathlib import Path -from typing import TypeAlias, cast - -from libcosimpy.CosimEnums import CosimVariableCausality, CosimVariableType, CosimVariableVariability # type: ignore -from libcosimpy.CosimExecution import CosimExecution # type: ignore -from libcosimpy.CosimLogging import CosimLogLevel, log_output_level # type: ignore -from libcosimpy.CosimManipulator import CosimManipulator # type: ignore -from libcosimpy.CosimObserver import CosimObserver # type: ignore - -from sim_explorer.utils.misc import from_xml, match_with_wildcard - -# type definitions -PyVal: TypeAlias = str | float | int | bool # simple python types / Json5 atom -Json5: TypeAlias = dict[str, "Json5Val"] # Json5 object -Json5List: TypeAlias = list["Json5Val"] # Json5 list -Json5Val: TypeAlias = PyVal | Json5 | Json5List # Json5 values - - -""" -sim_explorer module for definition and execution of simulation experiments -* read and compile the case definitions from configuration file - Note that Json5 is here restriced to 'ordered keys' and 'unique keys within an object' -* set the start variables for a given case -* manipulate variables according to conditions during the simulation run -* save requested variables at given communication points during a simulation run -* check the validity of results when saving variables - -With respect to MVx in general, this module serves the preparation of start conditions for smart testing. -""" - - -class CaseInitError(Exception): - """Special error indicating that something is wrong during initialization of cases.""" - - pass - - -class CaseUseError(Exception): - """Special error indicating that something is wrong during usage of cases.""" - - pass - - -class SimulatorInterface: - """Class providing the interface to the simulator itself. - This is designed for OSP and needs to be overridden for other types of simulator. - - Provides the following functions: - - * set_variable_value: Set variable values initially or at communication points - * get_variable_value: Get variable values at communication points - * match_components: Identify component instances based on (tuple of) (short) names - * match_variables: Identify component variables of component (instance) matching a (short) name - - - A system model might be defined through an instantiated simulator or explicitly through the .fmu files. - Unfortunately, an instantiated simulator does not seem to have functions to access the FMU, - therefore only the (reduced) info from the simulator is used here (FMUs not used directly). - - Args: - system (Path): Path to system model definition file - name (str)="System": Possibility to provide an explicit system name (if not provided by system file) - description (str)="": Optional possibility to provide a system description - simulator (CosimExecution)=None: Optional possibility to insert an existing simulator object. - Otherwise this is generated through CosimExecution.from_osp_config_file(). - log_level (CosimLogLevel): Per default the level is set to FATAL, - but it can be set to TRACE, DEBUG, INFO, WARNING, ERROR or FATAL (e.g. for debugging purposes) - """ - - def __init__( - self, - system: Path | str = "", - name: str | None = None, - description: str = "", - simulator: CosimExecution | None = None, - log_level: CosimLogLevel = CosimLogLevel.FATAL, - ): - self.name = name # overwrite if the system includes that - self.description = description # overwrite if the system includes that - self.sysconfig: Path | None = None - log_output_level(log_level) - self.simulator: CosimExecution - if simulator is None: # instantiate the simulator through the system config file - self.sysconfig = Path(system) - assert self.sysconfig.exists(), f"File {self.sysconfig.name} not found" - ck, msg = self._check_system_structure(self.sysconfig) - assert ck, msg - self.simulator = cast(CosimExecution, self._simulator_from_config(self.sysconfig)) - else: - self.simulator = simulator - self.components = self.get_components() # dict of {component name : modelId} - # Instantiate a suitable manipulator for changing variables. - self.manipulator = CosimManipulator.create_override() - assert self.simulator.add_manipulator(manipulator=self.manipulator), "Could not add manipulator object" - - # Instantiate a suitable observer for collecting results. - self.observer = CosimObserver.create_last_value() - assert self.simulator.add_observer(observer=self.observer), "Could not add observer object" - self.message = "" # possibility to save additional message for (optional) retrieval by client - - @property - def path(self): - return self.sysconfig.resolve().parent if self.sysconfig is not None else None - - def _check_system_structure(self, file: Path): - """Check the OspSystemStructure file. Used in cases where the simulatorInterface is instantiated from Cases.""" - el = from_xml(file) - assert isinstance(el, ET.Element), f"ElementTree element expected. Found {el}" - ns = el.tag.split("{")[1].split("}")[0] - msg = "" - for s in el.findall(".//{*}Simulator"): - if not Path(Path(file).parent / s.get("source", "??")).exists(): - msg += f"Component {s.get('name')}, source {s.get('source','??')} not found. NS:{ns}" - return (not len(msg), msg) - - def reset(self): # , cases:Cases): - """Reset the simulator interface, so that a new simulation can be run.""" - assert isinstance(self.sysconfig, Path), "Simulator resetting does not work with explicitly supplied simulator." - assert self.sysconfig.exists(), "Simulator resetting does not work with explicitly supplied simulator." - assert isinstance(self.manipulator, CosimManipulator) - assert isinstance(self.observer, CosimObserver) - # self.simulator = self._simulator_from_config(self.sysconfig) - self.simulator = CosimExecution.from_osp_config_file(str(self.sysconfig)) - assert self.simulator.add_manipulator(manipulator=self.manipulator), "Could not add manipulator object" - assert self.simulator.add_observer(observer=self.observer), "Could not add observer object" - # for case in cases: - - def _simulator_from_config(self, file: Path): - """Instantiate a simulator object through the a suitable configuration file. - Intended for use case 1 when Cases are in charge. - """ - if file.is_file(): - _type = "ssp" if file.name.endswith(".ssp") else "osp" - # file = file.parent - else: # a directory. Find type - _type = "osp" - for child in file.iterdir(): - if child.is_file(): - if child.name.endswith(".ssp"): - _type = "ssp" - file = file / child - break - elif child.name.endswith(".xml"): - file = file / child - xml = from_xml(file) - assert isinstance(xml, ET.Element), f"An ET.Element is ixpected here. Found {xml}" - if xml.tag.endswith("OspSystemStructure"): - break - if _type == "osp": - xml = from_xml(file) - assert isinstance(xml, ET.Element), f"An ET.Element is ixpected here. Found {xml}" - assert xml.tag.endswith("OspSystemStructure"), f"File {file} not an OSP structure file" - return CosimExecution.from_osp_config_file(str(file)) - else: - return CosimExecution.from_ssp_file(str(file)) - - def same_model(self, ref: int, refs: list[int] | set[int]): - ref_vars = self.get_variables(ref) - for r in refs: - r_vars = self.get_variables(r) - yield (r, r_vars == ref_vars) - - def get_components(self, model: int = -1) -> dict: - """Provide a dict of `{ component_instances_name : model_ID, ...}` in the system model. - For each component a unique ID per basic model (FMU) is used. - In this way, if comps[x]==comps[y] the components x and y relate to the same basic model. - If model != -1, only the components (instances) related to model are returned. - """ - comps = {} - if self.simulator is None: - pass # nothing to do we return an empty dict - - elif model >= 0: # use self.components to extract only components related to the provided model - for comp, mod in self.components.items(): - if mod == model: - comps.update({comp: self.components[comp]}) - - else: - comp_infos = self.simulator.slave_infos() - for comp in comp_infos: - for r, same in self.same_model(comp.index, set(comps.values())): - if same: - comps.update({comp.name.decode(): r}) - break - if comp.name.decode() not in comps: # new model - comps.update({comp.name.decode(): comp.index}) - return comps - - def get_models(self) -> list: - """Get the list of basic models based on self.components.""" - models = [] - for _, m in self.components.items(): - if m not in models: - models.append(m) - return models - - def match_components(self, comps: str | tuple[str, ...]) -> tuple[str, tuple]: - """Identify component (instances) based on 'comps' (component alias or tuple of aliases). - comps can be a (tuple of) full component names or component names with wildcards. - Returned components shall be based on the same model. - """ - if isinstance(comps, str): - comps = (comps,) - collect = [] - model = "" - for c in comps: - for k, v in self.components.items(): - if match_with_wildcard(c, k): - if not len(model): - model = v - if v == model and k not in collect: - collect.append(k) - return (model, tuple(collect)) - - def match_variables(self, component: str, varname: str) -> tuple[int]: - """Based on an example component (instance), identify unique variables starting with 'varname'. - The returned information applies to all instances of the same model. - The variables shall all be of the same type, causality and variability. - - Args: - component: component instance varname. - varname (str): the varname to search for. This can be the full varname or only the start of the varname - If only the start of the varname is supplied, all matching variables are collected. - - Returns - ------- - Tuple of value references - """ - - def accept_as_alias(org: str) -> bool: - """Decide whether the alias can be accepted with respect to org (uniqueness).""" - if not org.startswith(varname): # necessary requirement - return False - rest = org[len(varname) :] - if not len(rest) or any(rest.startswith(c) for c in ("[", ".")): - return True - return False - - var = [] - assert len(self.components), "Need the dictionary of components before maching variables" - - accepted = None - variables = self.get_variables(component) - for k, v in variables.items(): - if accept_as_alias(k): - if accepted is None: - accepted = v - assert all( - v[e] == accepted[e] for e in ("type", "causality", "variability") - ), f"Variable {k} matches {varname}, but properties do not match" - var.append(v["reference"]) - # for sv in model.findall(".//ScalarVariable"): - # if sv.get("varname", "").startswith(varname): - # if len(sv.get("varname")) == len(varname): # full varname. Not part of vector - # return (sv,) - # if len(var): # check if the var are compliant so that they fit into a 'vector' - # for prop in ("causality", "variability", "initial"): - # assert var[0].get(prop, "") == sv.get( - # prop, "" - # ), f"Model {model.get('modelvarname')}, alias {varname}: The property {prop} of variable {var[0].get('varname')} and {sv.get('varname')} are not compliant with combining them in a 'vector'" - # assert ( - # var[0][0].tag == sv[0].tag - # ), f"Model {model.get('modelName')}, alias {varname}: The variable types of {var[0].get('name')} and {sv.get('name')} shall be equal if they are combined in a 'vector'" - # var.append(sv) - return tuple(var) - - def is_output_var(self, comp: int, ref: int) -> bool: - for idx in range(self.simulator.num_slave_variables(comp)): - struct = self.simulator.slave_variables(comp)[idx] - if struct.reference == ref: - return struct.causality == 2 - return False - - def get_variables(self, comp: str | int, single: int | str | None = None, as_numbers: bool = True) -> dict: - """Get the registered variables for a given component from the simulator. - - Args: - component (str, int): The component name or its index within the model - single (int,str): Optional possibility to return a single variable. - If int: by valueReference, else by name. - as_numbers (bool): Return the enumerations as integer numbers (if True) or as names (if False) - - Returns - ------- - A dictionary of variable {names:info, ...}, where info is a dictionary containing reference, type, causality and variability - """ - if isinstance(comp, str): - component = self.simulator.slave_index_from_instance_name(comp) - if component is None: # component not found - return {} - elif isinstance(comp, int): - if comp < 0 or comp >= self.simulator.num_slaves(): # invalid id - return {} - component = comp - else: - raise AssertionError(f"Unallowed argument {comp} in 'get_variables'") - variables = {} - for idx in range(self.simulator.num_slave_variables(component)): - struct = self.simulator.slave_variables(component)[idx] - if ( - single is None - or (isinstance(single, int) and struct.reference == single) - or struct.name.decode() == single - ): - typ = struct.type if as_numbers else CosimVariableType(struct.type).name - causality = struct.causality if as_numbers else CosimVariableCausality(struct.causality).name - variability = struct.variability if as_numbers else CosimVariableVariability(struct.variability).name - variables.update( - { - struct.name.decode(): { - "reference": struct.reference, - "type": typ, - "causality": causality, - "variability": variability, - } - } - ) - return variables - - # def identify_variable_groups(self, component: str, include_all: bool = False) -> dict[str, any]: - # """Try to identify variable groups of the 'component', based on the assumption that variable names are structured. - # - # This function is experimental and designed as an aid to define variable aliases in case studies. - # Rule: variables must be of same type, causality and variability and must start with a common name to be in the same group. - # Note: The function assumes access to component model fmu files. - # """ - # - # def max_match(txt1: str, txt2: str) -> int: - # """Check equality of txt1 and txt2 letter for letter and return the position of first divergence.""" - # i = 0 - # for i, c in enumerate(txt1): - # if txt2[i] != c: - # return i - # return i - # - # assert component in self.components, f"Component {component} was not found in the system model" - # - # if not isinstance(self.components[component], Path): - # print(f"The fmu of of {component} does not seem to be accessible. {component} is registered as {self.components[component]}", - # ): - # return {} - # variables = from_xml(self.components[component], "modelDescription.xml").findall(".//ScalarVariable") - # groups = {} - # for i, var in enumerate(variables): - # if var is not None: # treated elements are set to None! - # group_name = "" - # group = [] - # for k in range(i + 1, len(variables)): # go through all other variables - # if variables[k] is not None: - # if ( - # var.attrib["causality"] == variables[k].attrib["causality"] - # and var.attrib["variability"] == variables[k].attrib["variability"] - # and var[0].tag == variables[k][0].tag - # and variables[k].attrib["name"].startswith(group_name) - # ): # is a candidate - # pos = max_match(var.attrib["name"], variables[k].attrib["name"]) - # if pos > len(group_name): # there is more commonality than so far identified - # group_name = var.attrib["name"][:pos] - # group = [i, k] - # elif len(group_name) and pos == len(group_name): # same commonality than so far identified - # group.append(k) - # if len(group_name): # var is in a group - # groups.update( - # { - # group_name: { - # "members": (variables[k].attrib["name"] for k in group), - # "description": var.get("description", ""), - # "references": (variables[k].attrib["valueReference"] for k in group), - # } - # } - # ) - # for k in group: - # variables[k] = None # treated - # if include_all: - # for var in variables: - # if var is not None: # non-grouped variable. Add that since include_all has been chosen - # groups.update( - # { - # var.attrib["name"]: { - # "members": (var.attrib["name"],), - # "description": var.get("description", ""), - # "references": (var.attrib["valueReference"],), - # } - # } - # ) - # return groups - - # def set_initial(self, instance: int, typ: int, var_refs: tuple[int], var_vals: tuple[PyVal]): - def set_initial(self, instance: int, typ: int, var_ref: int, var_val: PyVal): - """Provide an _initial_value set function (OSP only allows simple variables). - The signature is the same as the manipulator functions slave_real_values()..., - only that variables are set individually and the type is added as argument. - """ - if typ == CosimVariableType.REAL.value: - return self.simulator.real_initial_value(instance, var_ref, self.pytype(typ, var_val)) - elif typ == CosimVariableType.INTEGER.value: - return self.simulator.integer_initial_value(instance, var_ref, self.pytype(typ, var_val)) - elif typ == CosimVariableType.STRING.value: - return self.simulator.string_initial_value(instance, var_ref, self.pytype(typ, var_val)) - elif typ == CosimVariableType.BOOLEAN.value: - return self.simulator.boolean_initial_value(instance, var_ref, self.pytype(typ, var_val)) - - def set_variable_value(self, instance: int, typ: int, var_refs: tuple[int], var_vals: tuple[PyVal]) -> bool: - """Provide a manipulator function which sets the 'variable' (of the given 'instance' model) to 'value'. - - Args: - instance (int): identifier of the instance model for which the variable is to be set - var_refs (tuple): Tuple of variable references for which the values shall be set - var_vals (tuple): Tuple of values (of the correct type), used to set model variables - """ - _vals = [self.pytype(typ, x) for x in var_vals] # ensure list and correct type - if typ == CosimVariableType.REAL.value: - return self.manipulator.slave_real_values(instance, list(var_refs), _vals) - elif typ == CosimVariableType.INTEGER.value: - return self.manipulator.slave_integer_values(instance, list(var_refs), _vals) - elif typ == CosimVariableType.BOOLEAN.value: - return self.manipulator.slave_boolean_values(instance, list(var_refs), _vals) - elif typ == CosimVariableType.STRING.value: - return self.manipulator.slave_string_values(instance, list(var_refs), _vals) - else: - raise CaseUseError(f"Unknown type {typ}") from None - - def get_variable_value(self, instance: int, typ: int, var_refs: tuple[int, ...]): - """Provide an observer function which gets the 'variable' value (of the given 'instance' model) at the time when called. - - Args: - instance (int): identifier of the instance model for which the variable is to be set - var_refs (tuple): Tuple of variable references for which the values shall be retrieved - """ - if typ == CosimVariableType.REAL.value: - return self.observer.last_real_values(instance, list(var_refs)) - elif typ == CosimVariableType.INTEGER.value: - return self.observer.last_integer_values(instance, list(var_refs)) - elif typ == CosimVariableType.BOOLEAN.value: - return self.observer.last_boolean_values(instance, list(var_refs)) - elif typ == CosimVariableType.STRING.value: - return self.observer.last_string_values(instance, list(var_refs)) - else: - raise CaseUseError(f"Unknown type {typ}") from None - - @staticmethod - def pytype(fmu_type: str | int, val: PyVal | None = None): - """Return the python type of the FMU type provided as string or int (CosimEnums). - If val is None, the python type object is returned. Else if boolean, true or false is returned. - """ - fmu_type_str = CosimVariableType(fmu_type).name if isinstance(fmu_type, int) else fmu_type - typ = { - "real": float, - "integer": int, - "boolean": bool, - "string": str, - "enumeration": Enum, - }[fmu_type_str.lower()] - - if val is None: - return typ - elif typ is bool: - if isinstance(val, str): - return "true" in val.lower() # should be fmi2True and fmi2False - elif isinstance(val, int): - return bool(val) - else: - raise CaseInitError(f"The value {val} could not be converted to boolean") - else: - return typ(val) - - @staticmethod - def default_initial(causality: int, variability: int, max_possible: bool = False) -> int: - """Return default initial setting as int, as initial setting is not explicitly available in OSP. See p.50 FMI2. - maxPossible = True chooses the the initial setting with maximum allowance. - - * Causality: input=0, parameter=1, output=2, calc.par.=3, local=4, independent=5 (within OSP) - * Initial: exact=0, approx=1, calculated=2, none=3. - """ - code = ( - (3, 3, 0, 3, 0, 3), - (3, 0, 3, 1, 1, 3), - (3, 0, 3, 1, 1, 3), - (3, 3, 2, 3, 2, 3), - (3, 3, 2, 3, 2, 3), - )[variability][causality] - if max_possible: - return (0, 1, 0, 3)[code] # first 'possible value' in table - else: - return (0, 2, 2, 3)[code] # default value in table - - def allowed_action(self, action: str, comp: int | str, var: int | str | tuple, time: float): - """Check whether the action would be allowed according to FMI2 rules, see FMI2.01, p.49. - - * Unfortunately, the OSP interface does not explicitly provide the 'initial' setting, - such that we need to assume the default value as listed on p.50. - * OSP does not provide explicit access to 'before initialization' and 'during initialization'. - The rules for these two stages are therefore not distinguished - * if a tuple of variables is provided, the variables shall have equal properties - in addition to the normal allowed rules. - - Args: - action (str): Action type, 'set', 'get', including init actions (set at time 0) - comp (int,str): The instantiated component within the system (as index or name) - var (int,str,tuple): The variable(s) (of component) as reference or name - time (float): The time at which the action will be performed - """ - - def _description(name: str, info: dict, initial: int) -> str: - descr = f"Variable {name}, causality {CosimVariableCausality(info['causality']).name}" - descr += f", variability {CosimVariableVariability(var_info['variability']).name}" - descr += f", initial {('exact','approx','calculated','none')[initial]}" - return descr - - def _check(cond, msg): - if cond: - self.message = msg - return True - return False - - _type, _causality, _variability = (-1, -1, -1) # unknown - if isinstance(var, (int, str)): - var = (var,) - for v in var: - variables = self.get_variables(comp, v) - if _check(len(variables) != 1, f"Variable {v} of component {comp} was not found"): - return False - name, var_info = next(variables.items().__iter__()) - if _type < 0 or _causality < 0 or _variability < 0: # define the properties and check whether allowed - _type = var_info["type"] - _causality = var_info["causality"] - _variability = var_info["variability"] - initial = SimulatorInterface.default_initial(_causality, _variability, True) - - if action == "get": # no restrictions on get - pass - elif action == "set": - if _check( - _variability == 0, - f"Variable {name} is defined as 'constant' and cannot be set", - ): - return False - if _check( - _variability == 0, - f"Variable {name} is defined as 'constant' and cannot be set", - ): - return False - - if time == 0: # initialization - # initial settings 'exact', 'approx' or 'input' - if _check( - not (initial in (0, 1) or _causality == 0), - _description(name, var_info, initial) + " cannot be set before or during initialization.", - ): - return False - else: # at communication points - # 'parameter', 'tunable' or 'input - if _check( - not ((_causality == 1 and _variability == 2) or _causality == 0), - _description(name, var_info, initial) + " cannot be set at communication points.", - ): - return False - else: # check whether the properties are equal - if _check( - _type != var_info["type"], - _description(name, var_info, initial) + f" != type {_type}", - ): - return False - if _check( - _causality != var_info["causality"], - _description(name, var_info, initial) + f" != causality { _causality}", - ): - return False - if _check( - _variability != var_info["variability"], - _description(name, var_info, initial) + f" != variability {_variability}", - ): - return False - return True - - def variable_name_from_ref(self, comp: int | str, ref: int) -> str: - for name, info in self.get_variables(comp).items(): - if info["reference"] == ref: - return name - return "" - - def component_name_from_id(self, idx: int) -> str: - """Retrieve the component name from the given index, or an empty string if not found.""" - for slave_info in self.simulator.slave_infos(): - if slave_info.index == idx: - return slave_info.name.decode() - return "" - - def component_id_from_name(self, name: str) -> int: - """Get the component id from the name. -1 if not found.""" - id = self.simulator.slave_index_from_instance_name(name) - return id if id is not None else -1 diff --git a/src/sim_explorer/system_interface.py b/src/sim_explorer/system_interface.py new file mode 100644 index 0000000..cb81ff4 --- /dev/null +++ b/src/sim_explorer/system_interface.py @@ -0,0 +1,569 @@ +from enum import Enum +from pathlib import Path +from typing import Any, Sequence, TypeAlias + +import numpy as np + +from sim_explorer.json5 import Json5 +from sim_explorer.utils.misc import from_xml, match_with_wildcard +from sim_explorer.utils.osp import read_system_structure_xml + +PyVal: TypeAlias = str | float | int | bool # simple python types / Json5 atom + + +class SystemInterface: + """Class providing the interface to the system itself, i.e. system information, component interface information + and the system co-simulation orchestrator (if simulations shall be run). + + This model provides the base class and is able to read system and component information. + To run simulations it must be overridden by a super class, e.g. SystemInterfaceOSP + + Provides the following functions: + + * set_variable_value: Set variable values initially or at communication points + * get_variable_value: Get variable values at communication points + * match_components: Identify component instances based on (tuple of) (short) names + * match_variables: Identify component variables of component (instance) matching a (short) name + + + A system model might be defined through an instantiated simulator or explicitly through the .fmu files. + Unfortunately, an instantiated simulator does not seem to have functions to access the FMU, + therefore only the (reduced) info from the simulator is used here (FMUs not used directly). + + Args: + structure_file (Path): Path to system model definition file + name (str)="System": Possibility to provide an explicit system name (if not provided by system file) + description (str)="": Optional possibility to provide a system description + log_level (str) = 'fatal': Per default the level is set to 'fatal', + but it can be set to 'trace', 'debug', 'info', 'warning', 'error' or 'fatal' (e.g. for debugging purposes) + **kwargs: Optional possibility to supply additional keyword arguments: + + * full_simulator_available=True to overwrite the oposite when called from a superclass + """ + + def __init__( + self, + structure_file: Path | str = "", + name: str | None = None, + description: str = "", + log_level: str = "fatal", + **kwargs, + ): + self.structure_file = Path(structure_file) + self.name = name # overwrite if the system includes that + self.description = description # overwrite if the system includes that + self.system_structure = SystemInterface.read_system_structure(self.structure_file) + self._models, self.components = self._get_models_components() + # self.simulator=None # derived classes override this to instantiate the system simulator + self.message = "" # possibility to save additional message for (optional) retrieval by client + self.log_level = log_level + if "full_simulator_available" in kwargs: + self.full_simulator_available = kwargs["full_simulator_available"] + else: + self.full_simulator_available = False # only system and components specification available. No simulation! + if not self.full_simulator_available: # we need a minimal version of variable info (no full ModelDescription) + for m, info in self._models.items(): + self._models[m].update({"variables": self._get_variables(info["source"])}) + + @property + def path(self): + return self.structure_file.resolve().parent + + @staticmethod + def read_system_structure(file: Path, fmus_exist: bool = True): + """Read the systemStructure file and perform checks. + + Returns + ------- + The system structure as (json) dict as if the structure was read through osp_system_structure_from_js5 + """ + assert file.exists(), f"System structure {file} not found" + if file.suffix == ".xml": # assume the standard OspSystemStructure.xml file + system_structure = read_system_structure_xml(file) + elif file.suffix in (".js5", ".json"): # assume the js5 variant of the OspSystemStructure + system_structure = Json5(file).js_py + elif file.suffix == ".ssp": + # see https://ssp-standard.org/publications/SSP10/SystemStructureAndParameterization10.pdf + raise NotImplementedError("The SSP file variant is not yet implemented") from None + else: + raise KeyError(f"Unknown file type {file.suffix} for System Structure file") from None + for comp in system_structure["Simulators"].values(): + comp["source"] = (file.parent / comp["source"]).resolve() + assert not fmus_exist or comp["source"].exists(), f"FMU {comp['source']} not found" + return system_structure + + def _get_models_components(self) -> tuple[dict, dict]: + """Get a dict of the models and a dict of components in the system: + {model-name : {'source':, 'components':[component-list], 'variables':{variables-dict} + {component-name : {'model':'model-name, }, ...}. + """ + mods = {} + components = {} + for k, v in self.system_structure["Simulators"].items(): + source = v["source"] + model = source.stem + if model not in mods: + mods.update({model: {"source": source, "components": [k]}}) + else: + mods[model]["components"].append(k) + assert k not in components, f"Duplicate component name {k} related to model {model} encountered" + components.update({k: model}) + return (mods, components) + + @property + def models(self) -> dict: + return self._models + + def match_components(self, comps: str | tuple[str, ...]) -> tuple[str, tuple]: + """Identify component (instances) based on 'comps' (component alias or tuple of aliases). + comps can be a (tuple of) full component names or component names with wildcards. + Returned components shall be based on the same model. + """ + if isinstance(comps, str): + comps = (comps,) + collect = [] + model = None + for c in comps: + for k, m in self.components.items(): + if match_with_wildcard(c, k): + if model is None: + model = m + if m == model and k not in collect: + collect.append(k) + assert model is not None and len(collect), f"No component match for {comps}" + return (model, tuple(collect)) + + def _get_variables(self, source: Path) -> dict[str, dict]: + """Get the registered variables for a given model (added to _models dict). + + Returns + ------- + A dictionary of variable {names:info, ...}, + where info is a dictionary containing reference, type, causality, variability and initial + """ + assert source.exists() and source.suffix == ".fmu", f"FMU file {source} not found or wrong suffix" + md = from_xml(source, sub="modelDescription.xml") + variables = {} + for sv in md.findall(".//ScalarVariable"): + name = sv.attrib.pop("name") + vr = int(sv.attrib.pop("valueReference")) + var: dict[str, Any] = {k: v for k, v in sv.attrib.items()} + var.update({"reference": vr}) + typ = sv[0] + var.update({"type": SystemInterface.pytype(typ.tag)}) + var.update(typ.attrib) + variables.update({name: var}) + return variables + + def model_from_component(self, comp: str | int) -> str: + """Find the model name from the component name or index.""" + if isinstance(comp, str): + return self.components[comp] + elif isinstance(comp, int): + for i, mod in enumerate(self.components.values()): + if i == comp: + return mod + return "" + else: + raise AssertionError(f"Unallowed argument {comp} in 'variables'") + + def variables(self, comp: str | int) -> dict: + """Get the registered variables for a given component from the system. + This is the default version which works without the full modelDescription inside self._models. + Can be overridden by super-classes which have the modelDescription available. + + Args: + comp (str, int): The component name or its index within the model + + Returns + ------- + A dictionary of variable {names:info, ...}, where info is a dictionary containing reference, type, causality and variability + """ + try: + mod = self.model_from_component(comp) + except KeyError as err: + raise Exception(f"Component {comp} not found: {err}") from None + try: + return self.models[mod]["variables"] + except Exception: + raise KeyError(f"Variables for {comp} not found. Components: {list(self.components.keys())}") from None + + def variable_iter(self, variables: dict, flt: int | str | Sequence): + """Get the variable dicts of the variables refered to by ids. + + Returns: Iterator over the dicts of the selected variables + """ + if isinstance(flt, (int, str)): + ids = [flt] + elif isinstance(flt, (tuple, list)): + ids = list(flt) + else: + raise ValueError(f"Unknown filter specification {flt} for variables") from None + if isinstance(ids[0], str): # by name + for i in ids: + if i in variables: + yield (i, variables[i]) + else: # by reference + for v, info in variables.items(): + if info["reference"] in ids: + yield (v, info) + + def match_variables(self, component: str, varname: str) -> tuple: + """Based on an example component (instance), identify unique variables starting with 'varname'. + The returned information applies to all instances of the same model. + The variables shall all be of the same type, causality and variability. + + Args: + component: component instance varname. + varname (str): the varname to search for. This can be the full varname or only the start of the varname + If only the start of the varname is supplied, all matching variables are collected. + + Returns + ------- + Tuple of tuples (name,value reference) + """ + + def accept_as_alias(org: str) -> bool: + """Decide whether the alias can be accepted with respect to org (uniqueness).""" + if not org.startswith(varname): # necessary requirement + return False + rest = org[len(varname) :] + if not len(rest) or any(rest.startswith(c) for c in ("[", ".")): + return True + return False + + var = [] + assert hasattr(self, "components"), "Need the dictionary of components before maching variables" + + accepted = None + for k, v in self.variables(component).items(): + if accept_as_alias(k): + if accepted is None: + accepted = v + assert all(v[e] == accepted[e] for e in ("type", "causality", "variability")), ( + f"Variable {k} matches {varname}, but properties do not match" + ) + var.append((k, v["reference"])) + return tuple(var) + + def variable_name_from_ref(self, comp: int | str, ref: int) -> str: + """Get the variable name from its component instant (id or name) and its valueReference.""" + for name, info in self.variables(comp).items(): + if info["reference"] == ref: + return name + return "" + + def component_name_from_id(self, idx: int) -> str: + """Retrieve the component name from the given index. + Return an empty string if not found. + """ + for i, k in enumerate(self.components.keys()): + if i == idx: + return k + return "" + + def component_id_from_name(self, name: str) -> int: + """Get the component id from the name. -1 if not found.""" + for i, k in enumerate(self.components.keys()): + if k == name: + return i + return -1 + + @staticmethod + def pytype(fmu_type: str, val: PyVal | None = None): + """Return the python type of the FMU type provided as string. + If val is None, the python type object is returned. Else if boolean, true or false is returned. + """ + typ = { + "real": float, + "integer": int, + "boolean": bool, + "string": str, + "enumeration": Enum, + }[fmu_type.lower()] + + if val is None: + return typ + elif typ is bool: + if isinstance(val, str): + return "true" in val.lower() # should be fmi2True and fmi2False + elif isinstance(val, int): + return bool(val) + else: + raise KeyError(f"The value {val} could not be converted to boolean") + else: + return typ(val) + + @staticmethod + def default_initial(causality: str, variability: str, only_default: bool = True) -> str | int | tuple: + """Return default initial setting as str. See p.50 FMI2. + With only_default, the single allowed value, or '' is returned. + Otherwise a tuple of possible values is returned where the default value is always listed first. + """ + col = {"parameter": 0, "calculated_parameter": 1, "input": 2, "output": 3, "local": 4, "independent": 5}[ + causality + ] + row = {"constant": 0, "fixed": 1, "tunable": 2, "discrete": 3, "continuous": 4}[variability] + init = ( + (-1, -1, -1, 7, 10, -3), + (1, 3, -4, -5, 11, -3), + (2, 4, -4, -5, 12, -3), + (-2, -2, 5, 8, 13, -3), + (-2, -2, 6, 9, 14, 15), + )[row][col] + if init < 0: # "Unallowed combination {variability}, {causality}. See '{chr(96-init)}' in FMI standard" + return init if only_default else (init,) + elif init in (1, 2, 7, 10): + return "exact" if only_default else ("exact",) + elif init in (3, 4, 11, 12): + return "calculated" if only_default else ("calculated", "approx") + elif init in (8, 9, 13, 14): + return "calculated" if only_default else ("calculated", "exact", "approx") + else: + return init if only_default else (init,) + + def allowed_action(self, action: str, comp: int | str, var: int | str | Sequence, time: float): + """Check whether the action would be allowed according to FMI2 rules, see FMI2.01, p.49. + + * if a tuple of variables is provided, the variables shall have equal properties + in addition to the normal allowed rules. + + Args: + action (str): Action type, 'set', 'get', including init actions (set at time 0) + comp (int,str): The instantiated component within the system (as index or name) + var (int,str,tuple): The variable(s) (of component) as reference or name + time (float): The time at which the action will be performed + """ + + def _description(name: str, info: dict, initial: int) -> str: + descr = f"Variable {name}, causality {var_info['causality']}" + descr += f", variability {var_info['variability']}" + descr += f", initial {_initial}" + return descr + + def _check(cond, msg): + if not cond: + self.message = msg + return False + return True + + _type, _causality, _variability = ("", "", "") # unknown + + variables = self.variables(comp) + for name, var_info in self.variable_iter(variables, var): + # if not _check(name in variables, f"Variable {name} of component {comp} was not found"): + # print("VARIABLES", variables) + # return False + # var_info = variables[name] + if _type == "" or _causality == "" or _variability == "": # define the properties and check whether allowed + _type = var_info["type"] + _causality = var_info["causality"] + _variability = var_info["variability"] + _initial = var_info.get("initial", SystemInterface.default_initial(_causality, _variability)) + + if action in ("get", "step"): # no restrictions on get + pass + elif action == "set": + if ( + time < 0 # before EnterInitializationMode + and not _check( + (_variability != "constant" and _initial in ("exact", "approx")), + f"Change of {name} before EnterInitialization", + ) + ): + return False + + elif ( + time == 0 # before ExitInitializationMode + and not _check( + (_variability != "constant" and (_initial == "exact" or _causality == "input")), + f"Change of {name} during Initialization", + ) + ): + return False + elif ( + time > 0 # at communication points + and not _check( + (_causality == "parameter" and _variability == "tunable") or _causality == "input", + f"Change of {name} at communication point", + ) + ): + return False + # additional rule for ModelExchange, not listed here + else: # check whether the properties are equal + if not _check(_type == var_info["type"], _description(name, var_info, _initial) + f" != type {_type}"): + return False + if not _check( + _causality == var_info["causality"], + _description(name, var_info, _initial) + f" != causality {_causality}", + ): + return False + if not _check( + _variability == var_info["variability"], + _description(name, var_info, _initial) + f" != variability {_variability}", + ): + return False + return True + + @classmethod + def update_refs_values( + cls, allrefs: tuple[int, ...], baserefs: tuple[int, ...], basevals: tuple, refs: tuple[int, ...], values: tuple + ): + """Update baserefs and basevals with refs and values according to all possible refs.""" + allvals = [None] * len(allrefs) + for i, r in enumerate(baserefs): + allvals[allrefs.index(r)] = basevals[i] + for i, r in enumerate(refs): + allvals[allrefs.index(r)] = values[i] + _refs: list = [] + _vals: list = [] + for i, v in enumerate(allvals): + if v is not None: + _refs.append(allrefs[i]) + _vals.append(allvals[i]) + return (tuple(_refs), tuple(_vals)) + + def comp_model_var(self, cref: int, vref: int | tuple[int]): + """Find the component name and the variable names from the provided reference(s).""" + model = None + for i, (_comp, m) in enumerate(self.components.items()): + if i == cref: + model = m + comp = _comp + break + assert model is not None, f"Model for component id {cref} not found" + refs = (vref,) if isinstance(vref, int) else vref + var_names = [] + for vr in refs: + var = None + for v, info in self.models[model]["variables"].items(): + if info["reference"] == vr: + var = v + break + assert var is not None, f"Reference {vr} not found in model {model}" + var_names.append(var) + return (comp, model, var_names) + + def _add_set( + self, actions: dict, time: float, cvar: str, comp: str, cvar_info: dict, values: tuple, rng: tuple | None = None + ): + """Perform final processing and add the set action to the list (if appropriate). + + Properties of set actions: + + * both full case variable settings and partial settings are allowed and must be considered + * actions are recorded as tuples of (case-variable, component-name, value-references, values) + + Args: + actions (dict): dict of get actions. The time slot is beforehand ensured. + time (float): the time at which the action is issued + cvar (str): the case variable name for which the action is performed + comp (str): the component name + cvar_info (dict): info about the case variable + values (tuple): tuple of values (correct type made sure) + rng (tuple)=None: Optional sub-range among the variables of cvar. None: whole variable + """ + refs = cvar_info["refs"] if rng is None else tuple([cvar_info["refs"][i] for i in rng]) + assert len(refs) == len(values), f"Number of variable refs {refs} != values {values} in {cvar}, {comp}" + for i, (_cvar, _comp, _refs, _values) in enumerate(actions[time]): # go through existing actions for time + if cvar == _cvar and comp == _comp: # the case variable and the component name match + refs, values = self.update_refs_values(cvar_info["refs"], _refs, _values, refs, values) + actions[time][i] = (cvar, comp, refs, values) # replace action + return + # new set action + actions[time].append((cvar, comp, refs, values)) + + def _add_get(self, actions: dict, time: float, cvar: str, comp: str, cvar_info: dict): + """Perform final processing and add the get action to the list (if appropriate). + + Properties of get actions: + + * concern always the whole case-variable (all elements. rng not used) + * are tuples of (case-variable, component-name, variable-references) + * are never overridden for same time (no duplicate get actions for same component and cvar) + + Args: + actions (dict): dict of get actions. The time slot is beforehand ensured. + time (float): the time at which the action is issued + cvar (str): the case variable name for which the action is performed + component (str): the component name for which the action is performed + cvar_info (dict): info about the case variable + """ + for _cvar, _comp, _vars in actions[time]: # go through existing actions for same time + if cvar == _cvar and comp == _comp: # match on case variable and component + return # the get action is already registered + actions[time].append((cvar, comp, cvar_info["refs"])) + + def add_actions( + self, + actions: dict, + act_type: str, + cvar: str, + cvar_info: dict, + values: tuple | None, + at_time: float, + stoptime: float, + rng: tuple[int, ...] | None = None, + ): + """Add specified actions to the provided action dict. + The action list is simulator-agnostic and need 'compilation' before they are used in a simulation. + + Args: + actions (dict): actions ('get' or 'set') registered so far + act_type (str): action type 'get', 'step' or 'set' + cvar (str): name of the case variable + cvar_info (dict): dict of variable info: {model, instances, names, refs, type, causality, variability} + see Cases.get_case_variables() for details. + values (PyVal) = None: Optional values (mandatory for 'set' actions) + at_time (float): time at which actions shall be triggered (may be scaled) + stoptime (float): simulation stop time (needed to handle 'step' actions) + rng (Iterable)=None: Optional range specification for compound variables (indices to address) + + Returns + ------- + Updated actions dict, where the whole dict is specific for get/set and new actions are added as + {at_time : [ (cvar, component-name, (variable-name-list)[, value-list, rng])}, + where value-list and rng are only present for set actions + at-time=-1 for get actions denote step actions + """ + assert isinstance(at_time, (float, int)), f"Actions require a defined time as float. Found {at_time}" + if at_time not in actions: + actions.update({at_time: []}) # make sure that there is a suitable slot + for comp in cvar_info["instances"]: + if act_type == "get" or (act_type == "step" and at_time == -1): # normal get or step without time spec + self._add_get(actions, at_time, cvar, comp, cvar_info) + elif act_type == "step" and at_time >= 0: # step actions with specified interval + for time in np.arange(start=at_time, stop=stoptime, step=at_time): + self._add_get(actions, time, cvar, comp, cvar_info) + + elif act_type == "set": + assert values is not None, f"Variable {cvar}: Value needed for 'set' actions." + self._add_set( + actions, at_time, cvar, comp, cvar_info, tuple([cvar_info["type"](x) for x in values]), rng + ) + else: + raise KeyError(f"Unknown action type {act_type} at time {at_time}") + + def do_action(self, time: int | float, act_info: tuple, typ: type): + """Do the action described by the tuple using OSP functions.""" + raise NotImplementedError("The method 'do_action()' cannot be used in SystemInterface") from None + return False + + def action_step(self, act_info: tuple, typ: type): + """Pre-compile the step action and return the partial function + so that it can be called at communication points. + """ + raise NotImplementedError("The method 'action_step()' cannot be used in SystemInterface") from None + return None + + def init_simulator(self): + """Instantiate and initialize the simulator, so that simulations can be run. + Perforemd separately from __init__ so that it can be repeated before simulation runs. + """ + raise NotImplementedError("The method 'init_simulator()' cannot be used in SystemInterface") from None + return False + + def run_until(self, time: int | float): + """Instruct the simulator to simulate until the given time.""" + raise NotImplementedError("The method 'run_until()' cannot be used in SystemInterface") from None + return False diff --git a/src/sim_explorer/system_interface_osp.py b/src/sim_explorer/system_interface_osp.py new file mode 100644 index 0000000..263cf99 --- /dev/null +++ b/src/sim_explorer/system_interface_osp.py @@ -0,0 +1,119 @@ +from functools import partial +from pathlib import Path +from typing import TypeAlias + +from libcosimpy.CosimExecution import CosimExecution +from libcosimpy.CosimLogging import CosimLogLevel, log_output_level # type: ignore +from libcosimpy.CosimManipulator import CosimManipulator # type: ignore +from libcosimpy.CosimObserver import CosimObserver # type: ignore + +from sim_explorer.system_interface import SystemInterface + +PyVal: TypeAlias = str | float | int | bool # simple python types / Json5 atom + + +class SystemInterfaceOSP(SystemInterface): + """Implements the SystemInterface as a OSP. + + Args: + structure_file (Path): Path to system model definition file + name (str)="System": Possibility to provide an explicit system name (if not provided by system file) + description (str)="": Optional possibility to provide a system description + log_level (str) = 'fatal': Per default the level is set to 'fatal', + but it can be set to 'trace', 'debug', 'info', 'warning', 'error' or 'fatal' (e.g. for debugging purposes) + **kwargs: Optional possibility to supply additional keyword arguments: + + * full_simulator_available=True to overwrite the oposite when called from a superclass + """ + + def __init__( + self, + structure_file: Path | str = "", + name: str | None = None, + description: str = "", + log_level: str = "fatal", + **kwargs, + ): + super().__init__(structure_file, name, description, log_level) + self.full_simulator_available = True # system and components specification + simulation capabilities + # Note: The initialization of the OSP simulator itself is performed in init_simulator() + # Since this needs to be repeated before every simulation + + def init_simulator(self): + """Instantiate and initialize the simulator, so that simulations can be run. + Perforemd separately from __init__ so that it can be repeated before simulation runs. + """ + log_output_level(CosimLogLevel[self.log_level.upper()]) + # ck, msg = self._check_system_structure(self.sysconfig) + # assert ck, msg + assert self.structure_file.exists(), "Simulator initialization requires the structure file." + self.simulator = CosimExecution.from_osp_config_file(str(self.structure_file)) + assert isinstance(self.simulator, CosimExecution) + # Instantiate a suitable manipulator for changing variables. + self.manipulator = CosimManipulator.create_override() + assert isinstance(self.manipulator, CosimManipulator) + assert self.simulator.add_manipulator(manipulator=self.manipulator), "Could not add manipulator object" + + # Instantiate a suitable observer for collecting results. + self.observer = CosimObserver.create_last_value() + assert isinstance(self.observer, CosimObserver) + assert self.simulator.add_observer(observer=self.observer), "Could not add observer object" + assert self.simulator.status().current_time == 0 + return not self.simulator.status().error_code + + def _action_func(self, act_type: int, var_type: type): + """Determine the correct action function and return it.""" + if act_type == 0: # initial settings + return { + float: self.simulator.real_initial_value, + int: self.simulator.integer_initial_value, + str: self.simulator.string_initial_value, + bool: self.simulator.boolean_initial_value, + }[var_type] + elif act_type == 1: # other set actions + return { + float: self.manipulator.slave_real_values, + int: self.manipulator.slave_integer_values, + bool: self.manipulator.slave_boolean_values, + str: self.manipulator.slave_string_values, + }[var_type] + else: # get actions + return { + float: self.observer.last_real_values, + int: self.observer.last_integer_values, + bool: self.observer.last_boolean_values, + str: self.observer.last_string_values, + }[var_type] + + def do_action(self, time: int | float, act_info: tuple, typ: type): + """Do the action described by the tuple using OSP functions.""" + if len(act_info) == 4: # set action + cvar, comp, refs, values = act_info + _comp = self.component_id_from_name(comp) + if time <= 0: # initial setting + func = self._action_func(0, typ) + for r, v in zip(refs, values, strict=False): + if not func(_comp, r, v): + return False + return True + + else: + return self._action_func(1, typ)(_comp, refs, values) + else: # get action + cvar, comp, refs = act_info + _comp = self.component_id_from_name(comp) + assert time >= 0, "Get actions for all communication points shall be pre-compiled" + return self._action_func(2, typ)(_comp, refs) + + def action_step(self, act_info: tuple, typ: type): + """Pre-compile the step action and return the partial function + so that it can be called at communication points. + """ + assert len(act_info) == 3, f"Exactly 3 arguments exected. Found {act_info}" + cvar, comp, refs = act_info + _comp = self.component_id_from_name(comp) + return partial(self._action_func(2, typ), _comp, refs) + + def run_until(self, time: int | float): + """Instruct the simulator to simulate until the given time.""" + return self.simulator.simulate_until(time) diff --git a/src/sim_explorer/utils/misc.py b/src/sim_explorer/utils/misc.py index 8b5802b..d8574c4 100644 --- a/src/sim_explorer/utils/misc.py +++ b/src/sim_explorer/utils/misc.py @@ -19,7 +19,7 @@ def match_with_wildcard(findtxt: str, matchtxt: str) -> bool: return m is not None -def from_xml(file: Path, sub: str | None = None, xpath: str | None = None) -> ET.Element | list[ET.Element]: +def from_xml(file: Path, sub: str | None = None) -> ET.Element: """Retrieve the Element root from a zipped file (retrieve sub), or an xml file (sub unused). If xpath is provided only the xpath matching element (using findall) is returned. """ @@ -40,7 +40,4 @@ def from_xml(file: Path, sub: str | None = None, xpath: str | None = None) -> ET except ET.ParseError as err: raise Exception(f"File '{file}' does not seem to be a proper xml file") from err - if xpath is None: - return et - else: - return et.findall(xpath) + return et diff --git a/src/sim_explorer/utils/osp.py b/src/sim_explorer/utils/osp.py index f42f778..db32d0d 100644 --- a/src/sim_explorer/utils/osp.py +++ b/src/sim_explorer/utils/osp.py @@ -1,5 +1,8 @@ import xml.etree.ElementTree as ET # noqa: N817 from pathlib import Path +from typing import Any + +from component_model.utils.xml import read_xml from sim_explorer.json5 import Json5 @@ -69,7 +72,7 @@ def make_initial_value(var: str, val: bool | int | float | str): """Make a element from the provided var dict.""" typ = {bool: "Boolean", int: "Integer", float: "Real", str: "String"}[type(val)] initial = ET.Element("InitialValue", {"variable": var}) - ET.SubElement(initial, typ, {"value": str(val)}) + ET.SubElement(initial, typ, {"value": ("false", "true")[int(val)] if isinstance(val, bool) else str(val)}) return initial _simulators = ET.Element("Simulators") @@ -83,10 +86,12 @@ def make_initial_value(var: str, val: bool | int | float | str): "stepSize": str(props.get("stepSize", base_step)), }, ) - if "initialValues" in props: - initial = ET.SubElement(simulator, "InitialValues") - for var, value in props["initialValues"].items(): + initial = ET.Element("InitialValues") + for var, value in props.items(): + if var not in ("source", "stepSize"): initial.append(make_initial_value(var, value)) + if len(initial): + simulator.append(initial) _simulators.append(simulator) # print(f"Model {m}: {simulator}. Length {len(simulators)}") # ET.ElementTree(simulators).write("Test.xml") @@ -206,3 +211,53 @@ def osp_system_structure_from_js5(file: Path, dest: Path | None = None): ) return ss + + +def read_system_structure_xml(file: Path) -> dict[str, Any]: + """Read the system structure in xml format and return as js5 dict, similar to ..._from_js5.""" + + def type_value(el: ET.Element): + typ = el.tag.split("}")[1] + value = el.get("value") + return {"Real": float, "Integer": int, "Boolean": bool, "String": str}[typ](value) + + el = read_xml(file) + assert el.tag.endswith("OspSystemStructure"), f" expected. Found {el.tag}" + ns = el.tag.split("{")[1].split("}")[0] + bss = el.find(".//BaseStepSize") or 0.01 + header = { + "xmlns": ns, + "version": el.get("version", "'0.1'"), + "StartTime": el.find(".//StartTime") or 0.0, + "Algorithm": el.find(".//Algorithm") or "fixedStep", + "BaseStepSize": bss, + } + + simulators: dict = {} + for sim in el.findall(".//{*}Simulator"): + props = { + "source": sim.get("source"), + "stepSize": sim.get("stepSize", bss), + } + for initial in sim.findall(".//{*}InitialValue"): + props.update({str(initial.get("variable")): type_value(initial[0])}) + assert "name" in sim.attrib, f"Required attribute 'name' not found in {sim.tag}, attrib:{sim.attrib}" + simulators.update({sim.get("name"): props}) + + structure = {"header": header, "Simulators": simulators} + connections: dict = {} + for c in ("Variable", "Signal", "Group", "SignalGroup"): + cons: list = [] + for con in el.findall(".//{*}" + c + "Connection"): + assert len(con) == 2, f"Two sub-elements expected. Found {len(con)}" + _con: list = [] + for i in range(2): + for p in con[i].attrib.values(): + _con.append(p) + cons.append(_con) + if len(cons): + connections.update({"Connections" + c: cons}) + if len(connections): + structure.update(connections) + + return structure diff --git a/src/sim_explorer/utils/paths.py b/src/sim_explorer/utils/paths.py index 7637b23..5938f38 100644 --- a/src/sim_explorer/utils/paths.py +++ b/src/sim_explorer/utils/paths.py @@ -11,7 +11,7 @@ def relative_path(p1: Path, p2: Path) -> str: _p2 = p2.parts for i in range(len(_p1), 1, -1): if _p1[:i] == _p2[:i]: - return f"{ '../..' +''.join('/'+p for p in _p1[i:])}" + return f"{'../..' + ''.join('/' + p for p in _p1[i:])}" break return "" diff --git a/tests/data/BouncingBall0/BouncingBall.cases b/tests/data/BouncingBall0/BouncingBall.cases index c93fe06..7eba2f6 100644 --- a/tests/data/BouncingBall0/BouncingBall.cases +++ b/tests/data/BouncingBall0/BouncingBall.cases @@ -3,6 +3,8 @@ header: { name : 'BouncingBall', description : 'Simple sim explorer with the basic BouncingBall FMU (ball dropped from h=1m', modelFile : "OspSystemStructure.xml", + simulator : "OSP" + logLevel : 'fatal', # possible levels: trace, debug, info, warning, error, fatal timeUnit : "second", variables : { g : ['bb', 'g', "Gravity acting on the ball"], diff --git a/tests/data/BouncingBall0/BouncingBall.fmu b/tests/data/BouncingBall0/BouncingBall.fmu index 8ff6b1b..fe755e6 100644 Binary files a/tests/data/BouncingBall0/BouncingBall.fmu and b/tests/data/BouncingBall0/BouncingBall.fmu differ diff --git a/tests/data/BouncingBall3D/BouncingBall3D.cases b/tests/data/BouncingBall3D/BouncingBall3D.cases index d43fb64..fd8cf51 100644 --- a/tests/data/BouncingBall3D/BouncingBall3D.cases +++ b/tests/data/BouncingBall3D/BouncingBall3D.cases @@ -2,7 +2,8 @@ name : 'BouncingBall3D', description : 'Simple sim explorer with the 3D BouncingBall FMU (3D position and speed', modelFile : "OspSystemStructure.xml", - logLevel : "FATAL", + simulator : "OSP" + logLevel : 'fatal', # possible levels: trace, debug, info, warning, error, fatal timeUnit : "second", variables : { g : ['bb', 'g', "Gravity acting on the ball"], diff --git a/tests/data/BouncingBall3D/crane_table.xml b/tests/data/BouncingBall3D/crane_table.xml new file mode 100644 index 0000000..e15cd3b --- /dev/null +++ b/tests/data/BouncingBall3D/crane_table.xml @@ -0,0 +1,15 @@ + + 0.0 + 0.01 + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/data/BouncingBall3D/systemModel.xml b/tests/data/BouncingBall3D/systemModel.xml new file mode 100644 index 0000000..e15cd3b --- /dev/null +++ b/tests/data/BouncingBall3D/systemModel.xml @@ -0,0 +1,15 @@ + + 0.0 + 0.01 + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/data/MobileCrane/MobileCrane.cases b/tests/data/MobileCrane/MobileCrane.cases index 96132e7..20b3e21 100644 --- a/tests/data/MobileCrane/MobileCrane.cases +++ b/tests/data/MobileCrane/MobileCrane.cases @@ -2,7 +2,8 @@ name : 'MobileCrane', description : 'sim explorer with the MobileCrane FMU (standalone)', modelFile : "OspSystemStructure.xml", - logLevel : "TRACE", + simulator : "OSP", + logLevel : 'trace', # possible levels: trace, debug, info, warning, error, fatal timeUnit : "second", variables : { p : ['mobileCrane', 'pedestal_boom', "Length and angle of pedestal. Use p[2] to set pedestal azimuth [degrees]"], diff --git a/tests/data/crane_table.js5 b/tests/data/MobileCrane/crane_table.js5 similarity index 79% rename from tests/data/crane_table.js5 rename to tests/data/MobileCrane/crane_table.js5 index 46812d6..57ed315 100644 --- a/tests/data/crane_table.js5 +++ b/tests/data/MobileCrane/crane_table.js5 @@ -3,10 +3,11 @@ header : { xmlns : "http://opensimulationplatform.com/MSMI/OSPSystemStructure", version : "0.1", StartTime : 0.0, + Algorithm : "fixedStep", BaseStepSize : 0.01, }, Simulators : { - simpleTable : {source: "SimpleTable.fmu", interpolate: True}, + simpleTable : {source: "../SimpleTable/SimpleTable.fmu", interpolate: True}, mobileCrane : {source: "MobileCrane.fmu" stepSize: 0.01, pedestal.pedestalMass: 5000.0, boom.boom[0]: 20.0}, }, diff --git a/tests/data/MobileCrane/crane_table.xml b/tests/data/MobileCrane/crane_table.xml new file mode 100644 index 0000000..e9d2ddd --- /dev/null +++ b/tests/data/MobileCrane/crane_table.xml @@ -0,0 +1,30 @@ + + 0.0 + 0.01 + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/data/MobileCrane/mobile_crane.py b/tests/data/MobileCrane/mobile_crane.py new file mode 100644 index 0000000..a21c60c --- /dev/null +++ b/tests/data/MobileCrane/mobile_crane.py @@ -0,0 +1,76 @@ +from crane_fmu.crane import Crane # , Animation + + +class MobileCrane(Crane): + """Simple mobile crane for FMU testing purposes. + The crane has a short pedestal, one variable-length stiff boom and a rope. + The size and weight of the various parts can be configured. + + Args: + name (str) : name of the crane type + description (str) : short description + author (str) + version (str) + pedestalMass (str) : mass of the pedestal - quantity and unit as string + pedestalHeight (str) : height (fixed) of the pedestal, with units + boomMass (str) : mass of the single boom, with units + boomLength0 (str) : minimum length of the boom, with units + boomLength1 (str) : maximum length of the boom, with units + """ + + def __init__( + self, + name: str = "mobile_crane", + description: str = "Simple mobile crane (for FMU testing) with short pedestal, one variable-length elevation boom and a rope", + author: str = "DNV, SEACo project", + version: str = "0.2", + pedestalMass: str = "10000.0 kg", + pedestalHeight: str = "3.0 m", + boomMass: str = "1000.0 kg", + boomLength0: str = "8 m", + boomLength1: str = "50 m", + rope_mass_range: tuple = ("50kg", "2000 kg"), + **kwargs, + ): + super().__init__( + name=name, description=description, author=author, version=version, **kwargs + ) + _ = self.add_boom( + name="pedestal", + description="The crane base, on one side fixed to the vessel and on the other side the first crane boom is fixed to it. The mass should include all additional items fixed to it, like the operator's cab", + mass=pedestalMass, + massCenter=(0.5, -1.0, 0.8), + boom=(pedestalHeight, "0deg", "0deg"), + boom_rng=(None, None, ("0deg", "360deg")), + ) + _ = self.add_boom( + name="boom", + description="The boom. Can be lifted and length can change within the given range", + mass=boomMass, + massCenter=(0.5, 0, 0), + boom=(boomLength0, "90deg", "0deg"), + boom_rng=((boomLength0, boomLength1), (0, "90deg"), None), + ) + _ = self.add_boom( + name="rope", + description="The rope fixed to the last boom. Flexible connection", + mass="50.0 kg", # so far basically the hook + massCenter=0.95, + mass_rng=rope_mass_range, + boom=("1e-6 m", "180deg", "0 deg"), + boom_rng=( + ("1e-6 m", boomLength1), + ("90deg", "270deg"), + ("-180deg", "180deg"), + ), + damping=50.0, + animationLW=2, + ) + # make sure that _comSub is calculated for all booms: + self.calc_statics_dynamics(None) + + def do_step(self, currentTime, stepSize): + status = super().do_step(currentTime, stepSize) + # print(f"Time {currentTime}, {self.rope_tip}") + # print(f"MobileCrane.do_step. Status {status}") + return status diff --git a/tests/data/Oscillator/DrivingForce.fmu b/tests/data/Oscillator/DrivingForce.fmu index 7efcf5e..c9a42d4 100644 Binary files a/tests/data/Oscillator/DrivingForce.fmu and b/tests/data/Oscillator/DrivingForce.fmu differ diff --git a/tests/data/Oscillator/ForcedOscillator.cases b/tests/data/Oscillator/ForcedOscillator.cases index 784b69e..173896b 100644 --- a/tests/data/Oscillator/ForcedOscillator.cases +++ b/tests/data/Oscillator/ForcedOscillator.cases @@ -2,7 +2,8 @@ name : 'ForcedOscillator', description : 'Test forced oscillator in various conditions', modelFile : "ForcedOscillator.xml", - logLevel : 'FATAL', # possible levels: TRACE, DEBUG, INFO, WARNING, ERROR, FATAL + simulator : "OSP" + logLevel : 'fatal', # possible levels: trace, debug, info, warning, error, fatal timeUnit : "second", # possible units: ns, us, ms, sec, min, h, d, or extensions of that, like 'second' variables : { k : ['osc', 'k', "The spring constant in N/m"], diff --git a/tests/data/Oscillator/HarmonicOscillator.fmu b/tests/data/Oscillator/HarmonicOscillator.fmu index d2974bf..5d6e4b9 100644 Binary files a/tests/data/Oscillator/HarmonicOscillator.fmu and b/tests/data/Oscillator/HarmonicOscillator.fmu differ diff --git a/tests/data/SimpleTable/test.cases b/tests/data/SimpleTable/test.cases index f5eaef2..bbfb350 100644 --- a/tests/data/SimpleTable/test.cases +++ b/tests/data/SimpleTable/test.cases @@ -2,6 +2,8 @@ header : { name : 'Testing', description : 'Simple sim explorer for testing purposes', + logLevel : 'info', + simulator : 'OSP' timeUnit : 'second', variables : { x : ['tab','outs','Outputs (3-dim)'], diff --git a/tests/data/template.cases b/tests/data/template.cases index b952ac7..c6234af 100644 --- a/tests/data/template.cases +++ b/tests/data/template.cases @@ -2,7 +2,8 @@ name : '', description : 'Simulation-exploration-description>', modelFile : "OspSystemStructure.xml", # this will often be ok - logLevel : 'FATAL', # possible levels: TRACE, DEBUG, INFO, WARNING, ERROR, FATAL + simulator : "OSP" + logLevel : 'fatal', # possible levels: trace, debug, info, warning, error, fatal timeUnit : "second", # possible units: ns, us, ms, sec, min, h, d, or extensions of that, like 'second' variables : { : [, , ], diff --git a/tests/test_BouncingBall.py b/tests/test_BouncingBall.py index 46a51de..8cc19d9 100644 --- a/tests/test_BouncingBall.py +++ b/tests/test_BouncingBall.py @@ -6,16 +6,16 @@ def nearly_equal(res: tuple, expected: tuple, eps=1e-7): - assert len(res) == len( - expected - ), f"Tuples of different lengths cannot be equal. Found {len(res)} != {len(expected)}" + assert len(res) == len(expected), ( + f"Tuples of different lengths cannot be equal. Found {len(res)} != {len(expected)}" + ) for i, (x, y) in enumerate(zip(res, expected, strict=False)): assert abs(x - y) < eps, f"Element {i} not nearly equal in {x}, {y}" def test_run_fmpy(show): """Test and validate the basic BouncingBall using fmpy and not using OSP or sim_explorer.""" - path = Path(Path(__file__).parent, "data/BouncingBall0/BouncingBall.fmu") + path = Path(__file__).parent / "data" / "BouncingBall0" / "BouncingBall.fmu" assert path.exists(), f"File {path} does not exist" stepsize = 0.01 result = simulate_fmu( @@ -61,3 +61,4 @@ def test_run_fmpy(show): if __name__ == "__main__": retcode = pytest.main(["-rA", "-v", __file__, "--show", "True"]) assert retcode == 0, f"Non-zero return code {retcode}" + # test_run_fmpy(show=True) diff --git a/tests/test_assertion.py b/tests/test_assertion.py index a9b1e31..31e0067 100644 --- a/tests/test_assertion.py +++ b/tests/test_assertion.py @@ -37,7 +37,10 @@ def test_ast(show): asserts = Assertion() asserts.register_vars( - {"x": {"instances": ("dummy",), "variables": (1, 2, 3)}, "y": {"instances": ("dummy2",), "variables": (1,)}} + { + "x": {"instances": ("dummy",), "names": ("x[0]", "x[1]", "x[2]")}, + "y": {"instances": ("dummy2",), "names": ("y[0]",)}, + } ) syms, funcs = asserts.expr_get_symbols_functions(expr) assert syms == ["x", "y"], f"SYMS: {syms}" @@ -65,7 +68,7 @@ def test_ast(show): asserts = Assertion() asserts.register_vars( - {"g": {"instances": ("bb",), "variables": (1,)}, "x": {"instances": ("bb",), "variables": (2, 3, 4)}} + {"g": {"instances": ("bb",), "names": ("g",)}, "x": {"instances": ("bb",), "names": ("x[0]", "x[1]", "x[2]")}} ) expr = "sqrt(2*bb_x[2] / bb_g)" # fully qualified variables with components a = ast.parse(expr, "", "exec") @@ -100,9 +103,9 @@ def test_assertion(): asserts.symbol("t") asserts.register_vars( { - "x": {"instances": ("dummy",), "variables": (2,)}, - "y": {"instances": ("dummy",), "variables": (3,)}, - "z": {"instances": ("dummy",), "variables": (4, 5)}, + "x": {"instances": ("dummy",), "names": ("x[0]",)}, + "y": {"instances": ("dummy",), "names": ("y[0]",)}, + "z": {"instances": ("dummy",), "names": ("z[0]", "z[1]")}, } ) asserts.expr("1", "t>8") @@ -216,8 +219,8 @@ def test_do_assert(show): for key, inf in res.inspect().items(): print(key, inf["len"], inf["range"]) info = res.inspect()["bb.v"] - assert info["len"] == 300 - assert info["range"] == [0.01, 3.0] + assert info["len"] == 301, f"Found {info['len']}" + assert info["range"] == [0.0, 3.0] asserts = cases.assertion # asserts.vector('x', (1,0,0)) # asserts.vector('v', (0,1,0)) @@ -257,9 +260,8 @@ def test_do_assert(show): if __name__ == "__main__": retcode = pytest.main(["-rA", "-v", __file__, "--show", "False"]) assert retcode == 0, f"Non-zero return code {retcode}" - import os - - os.chdir(Path(__file__).parent.absolute() / "test_working_directory") + # import os + # os.chdir(Path(__file__).parent.absolute() / "test_working_directory") # test_temporal() # test_ast( show=True) # test_globals_locals() diff --git a/tests/test_bouncing_ball_3d.py b/tests/test_bouncing_ball_3d.py index 4e6a84e..2af6231 100644 --- a/tests/test_bouncing_ball_3d.py +++ b/tests/test_bouncing_ball_3d.py @@ -1,15 +1,16 @@ from math import sqrt from pathlib import Path +import pytest from fmpy import plot_result, simulate_fmu from sim_explorer.case import Case, Cases def arrays_equal(res: tuple, expected: tuple, eps=1e-7): - assert len(res) == len( - expected - ), f"Tuples of different lengths cannot be equal. Found {len(res)} != {len(expected)}" + assert len(res) == len(expected), ( + f"Tuples of different lengths cannot be equal. Found {len(res)} != {len(expected)}" + ) for i, (x, y) in enumerate(zip(res, expected, strict=False)): assert abs(x - y) < eps, f"Element {i} not nearly equal in {x}, {y}" @@ -61,7 +62,7 @@ def check_case( case.run() # run the case and return results as results object results = case.res # the results object assert results.res.jspath(path="$.header.case", typ=str, errorMsg=True) == case.name - # default initial settings, superceded by base case values + # default initial settings, superceeded by base case values x = [0, 0, x_z] # z-value is in inch =! 1m! v = [1.0, 0, 0] # adjust to case settings: @@ -86,7 +87,10 @@ def check_case( # check outputs after first step: assert results.res.jspath(path="$['0'].bb.e") == e, "??Initial value of e" assert results.res.jspath(path="$['0'].bb.g") == g, "??Initial value of g" - assert results.res.jspath(path="$['0'].bb.['x[2]']") == x[2], "??Initial value of x[2]" + assert results.res.jspath(path="$['0'].bb.x[2]") == x[2], "??Initial value of x[2]" + # print("0.01", results.res.jspath(path="$['0.01']")) + # print( results.res.jspath(path="$['0.01'].bb.x"), (dt, 0, x[2] - 0.5 * g * dt**2 / hf)) + arrays_equal( res=results.res.jspath(path="$['0.01'].bb.x"), expected=(dt, 0, x[2] - 0.5 * g * dt**2 / hf), @@ -115,7 +119,7 @@ def check_case( ddt = t_before + dt - t_bounce # time from bounce to end of step x_bounce2 = x_bounce + 2 * v_bounce * e * 1.0 * e / g arrays_equal( - res=results.res.jspath(path=f"$['{t_before+dt}'].bb.x"), + res=results.res.jspath(path=f"$['{t_before + dt}'].bb.x"), expected=( t_bounce * v[0] + v[0] * e * ddt, 0, @@ -124,10 +128,10 @@ def check_case( ) arrays_equal( - res=results.res.jspath(path=f"$['{t_before+dt}'].bb.v"), + res=results.res.jspath(path=f"$['{t_before + dt}'].bb.v"), expected=(e * v[0], 0, (v_bounce * e - g * ddt)), ) - assert abs(results.res.jspath(path=f"$['{t_before+dt}'].bb.['x_b']")[0] - x_bounce2) < 1e-9 + assert abs(results.res.jspath(path=f"$['{t_before + dt}'].bb.['x_b']")[0] - x_bounce2) < 1e-9 # from bounce to bounce v_x, v_z, t_b, x_b = ( v[0], @@ -144,15 +148,15 @@ def check_case( t_b += delta_t x_b += v_x * delta_t _tb = int(t_b * tfac) / tfac - if results.res.jspath(path=f"$['{_tb+dt}']") is None: + if results.res.jspath(path=f"$['{_tb + dt}']") is None: break _z = results.res.jspath(path=f"$['{_tb}'].bb.x")[2] # z_ = results.res.jspath(path=f"$['{_tb+dt}'].bb.x")[2] _vz = results.res.jspath(path=f"$['{_tb}'].bb.v")[2] - vz_ = results.res.jspath(path=f"$['{_tb+dt}'].bb.v")[2] + vz_ = results.res.jspath(path=f"$['{_tb + dt}'].bb.v")[2] _vx = results.res.jspath(path=f"$['{_tb}'].bb.v")[0] - vx_ = results.res.jspath(path=f"$['{_tb+dt}'].bb.v")[0] - assert abs(_z) < x[2] * 5e-2, f"Bounce {n}@{t_b}. z-position {_z} should be close to 0 ({x[2]*5e-2})" + vx_ = results.res.jspath(path=f"$['{_tb + dt}'].bb.v")[0] + assert abs(_z) < x[2] * 5e-2, f"Bounce {n}@{t_b}. z-position {_z} should be close to 0 ({x[2] * 5e-2})" if delta_t > 2 * dt: assert _vz < 0 and vz_ > 0, f"Bounce {n}@{t_b}. Expected speed sign change {_vz}-{vz_}when bouncing" assert _vx * e == vx_, f"Bounce {n}@{t_b}. Reduced speed in x-direction. {_vx}*{e}!={vx_}" @@ -205,11 +209,10 @@ def test_run_cases(): if __name__ == "__main__": - # retcode = pytest.main(["-rA", "-v", __file__, "--show", "True"]) - # assert retcode == 0, f"Non-zero return code {retcode}" - import os - - os.chdir(Path(__file__).parent.absolute() / "test_working_directory") + retcode = pytest.main(["-rA", "-v", __file__, "--show", "True"]) + assert retcode == 0, f"Non-zero return code {retcode}" + # import os + # os.chdir(Path(__file__).parent.absolute() / "test_working_directory") # test_make_fmu() # test_run_fmpy( show=True) - test_run_cases() + # test_run_cases() diff --git a/tests/test_case.py b/tests/test_case.py index 12ee142..7bff43c 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -6,7 +6,7 @@ from sim_explorer.case import Case, Cases from sim_explorer.json5 import Json5 -from sim_explorer.simulator_interface import SimulatorInterface +from sim_explorer.system_interface import SystemInterface @pytest.fixture @@ -72,7 +72,7 @@ def _make_cases(): } js = Json5(json5) js.write("data/test.cases") - _ = SimulatorInterface("data/OspSystemStructure.xml", "testSystem") + _ = SystemInterface("data/OspSystemStructure.xml") _ = Cases("data/test.cases") @@ -151,14 +151,6 @@ def check_value(case: Case, var: str, val: Any): check_value(case.parent, var, val) -def str_act(action) -> str: - """Prepare a human readable view of the action""" - if len(action.args) == 3: - return f"{action.func.__name__}(inst={action.args[0]}, type={action.args[1]}, ref={action.args[2]}" - else: - return f"{action.func.__name__}(inst={action.args[0]}, type={action.args[1]}, ref={action.args[2]}, val={action.args[3]}" - - # @pytest.mark.skip(reason="Deactivated") def test_case_set_get(simpletable): """Test of the features provided by the Case class""" @@ -182,45 +174,24 @@ def test_case_set_get(simpletable): ], "Hierarchy of caseX not as expected" check_value(caseX, "i", True) check_value(caseX, "stopTime", 10) - print("caseX, act_set[0.0]:") - for act in caseX.act_set[0.0]: - print(str_act(act)) + assert caseX.act_set[0.0][0] == ("i", "tab", (3,), (True,)), f"Found {caseX.act_set[0.0][0]}" assert caseX.special["stopTime"] == 10, f"Erroneous stopTime {caseX.special['stopTime']}" - # print(f"ACT_SET: {caseX.act_set[0.0][0]}") #! set_initial, therefore no tuples! - assert caseX.act_set[0.0][0].func.__name__ == "set_initial", "function name" - assert caseX.act_set[0.0][0].args[0] == 0, "model instance" - assert caseX.act_set[0.0][0].args[1] == 3, f"variable type {caseX.act_set[0.0][0].args[1]}" - assert caseX.act_set[0.0][0].args[2] == 3, f"variable ref {caseX.act_set[0.0][0].args[2]}" - assert caseX.act_set[0.0][0].args[3], f"variable value {caseX.act_set[0.0][0].args[3]}" - # print(caseX.act_set[0.0][0]) - assert caseX.act_set[0.0][0].args[0] == 0, "model instance" - assert caseX.act_set[0.0][0].args[1] == 3, f"variable type {caseX.act_set[0.0][0].args[1]}" - assert caseX.act_set[0.0][0].args[2] == 3, f"variable ref {caseX.act_set[0.0][0].args[2]}" - assert caseX.act_set[0.0][0].args[3] is True, f"variable value {caseX.act_set[0.0][0].args[3]}" - # print(f"ACT_GET: {caseX.act_get}") - assert caseX.act_get[1e9][0].args[0] == 0, "model instance" - assert caseX.act_get[1e9][0].args[1] == 0, "variable type" - assert caseX.act_get[1e9][0].args[2] == (0,), f"variable refs {caseX.act_get[1e9][0].args[2]}" - # print( "PRINT", caseX.act_get[-1][0].args[2]) - assert caseX.act_get[-1][0].args[2] == ( - 0, - 1, - 2, - ), f"variable refs of step actions {caseX.act_get[-1][0]}" - for t in caseX.act_get: - for act in caseX.act_get[t]: - print(str_act(act)) - # print("RESULTS", simpletable.run_case(simpletable.base, dump=True)) - - -# cases.base.plot_time_series( ['h'], 'TestPlot') + assert list(caseX.act_get.keys()) == [-1, 0.0, 1000000000.0], "Get-action times" + # print(f"ACT_GET: {caseX.act_get[-1][0]}") + assert caseX.act_get[-1][0] == ("x", "tab", (0, 1, 2)) + # print(f"ACT_GET: {caseX.act_get[1e9][0]}") + assert caseX.act_get[1e9][0] == ("x", "tab", (0, 1, 2)) + assert caseX.act_get[-1][0] == ("x", "tab", (0, 1, 2)) + assert caseX.act_get[0.0][0] == ("i", "tab", (3,)) + assert caseX.act_get[1000000000][0] == ("x", "tab", (0, 1, 2)) if __name__ == "__main__": retcode = pytest.main(["-rA", "-v", __file__]) assert retcode == 0, f"Non-zero return code {retcode}" - # import os - # os.chdir(Path(__file__).parent.absolute() / "test_working_directory") + import os + + os.chdir(Path(__file__).parent.absolute() / "test_working_directory") # test_fixture(_simpletable()) # test_case_at_time(_simpletable()) # test_case_range(_simpletable()) diff --git a/tests/test_cases.py b/tests/test_cases.py index 33f4050..2ff2dbf 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -3,39 +3,20 @@ import pytest from sim_explorer.case import Case, Cases -from sim_explorer.simulator_interface import SimulatorInterface - -# def test_tuple_iter(): -# """Test of the features provided by the Case class""" -# -# def check(gen: Generator, expectation: list): -# lst = [x[0] for x in gen] -# assert lst == expectation, f"Expected: {expectation}. Found: {lst}" -# -# tpl20 = tuple(range(20)) -# check(tuple2_iter(tpl20, tpl20, "3"), [3]) -# check(tuple2_iter(tpl20, tpl20, "3:7"), [3, 4, 5, 6, 7]) -# check(tuple2_iter(tpl20, tpl20, ":3"), list(range(0, 4))) -# check(tuple2_iter(tpl20, tpl20, "17:"), list(range(17, 20))) -# check(tuple2_iter(tpl20, tpl20, "10:-5"), list(range(10, 16))) -# check(tuple2_iter(tpl20, tpl20, ":"), list(range(20))) -# check(tuple2_iter(tpl20, tpl20, "1,3,4,9"), [1, 3, 4, 9]) def test_cases_management(): cases = Cases(Path(__file__).parent / "data" / "SimpleTable" / "test.cases") assert isinstance(cases.base.act_get, dict) and len(cases.base.act_get) > 0 - assert cases.case_var_by_ref(0, 1) == ( - "x", - (1,), - ), f"Case variable of model 0, ref 1: {cases.case_var_by_ref( 0, 1)}" - assert cases.case_var_by_ref("tab", 1) == ("x", (1,)), "Same with model by name" + + assert cases.simulator.comp_model_var(0, 1) == ("tab", "SimpleTable", ["outs[1]"]) + assert cases.simulator.comp_model_var(0, 1) == ("tab", "SimpleTable", ["outs[1]"]) + assert cases.simulator.component_name_from_id(0) == "tab" def test_cases(): """Test of the features provided by the Cases class""" - sim = SimulatorInterface(str(Path(__file__).parent / "data" / "BouncingBall0" / "OspSystemStructure.xml")) - cases = Cases(Path(__file__).parent / "data" / "BouncingBall0" / "BouncingBall.cases", sim) + cases = Cases(Path(__file__).parent / "data" / "BouncingBall0" / "BouncingBall.cases") c: str | Case print(cases.info()) @@ -72,11 +53,11 @@ def test_cases(): assert restitution_case is not None and restitution_case.case_by_name("restitutionAndGravity") is not None, msg # variables (aliases) assert cases.variables["h"]["instances"] == ("bb",) - assert cases.variables["h"]["variables"] == (1,) + assert cases.variables["h"]["refs"] == (1,) assert cases.variables["h"]["description"] == "Position (z) of the ball" - assert cases.variables["h"]["type"] == 0 - assert cases.variables["h"]["causality"] == 2 - assert cases.variables["h"]["variability"] == 4 + assert cases.variables["h"]["type"] is float + assert cases.variables["h"]["causality"] == "output", f"Found {cases.variables['h']['causality']}" + assert cases.variables["h"]["variability"] == "continuous", f"Found {cases.variables['h']['variability']}" vs = dict((k, v) for k, v in cases.variables.items() if k.startswith("v")) assert all(x in vs for x in ("v_min", "v_z", "v")) diff --git a/tests/test_json5.py b/tests/test_json5.py index bfd36f6..38a0398 100644 --- a/tests/test_json5.py +++ b/tests/test_json5.py @@ -109,9 +109,9 @@ def test_jpath(ex): assert ex.jspath("$..book[2].author", typ=float, errorMsg=False) is None, "Fail silently" with pytest.raises(ValueError) as err: found = ex.jspath("$..book[2].author", typ=float, errorMsg=True) - assert ( - str(err.value) == "$..book[2].author matches, but type does not match ." - ), f"ERR:{err.value}" + assert str(err.value) == "$..book[2].author matches, but type does not match .", ( + f"ERR:{err.value}" + ) # some selected jsonpath extensions: # not yet tested (not found out how it is done): @@ -210,9 +210,9 @@ def test_json5_syntax(): js = Json5("Hello 'W\norld'", 0).js5 assert Json5("Hello 'W\norld'", 0).js5[10] == "\n", "newline within quotations should not be replaced" assert Json5("He'llo 'Wo'rld'", 0).js5 == "{ He'llo 'Wo'rld' }", "Handling of single quotes not correct" - assert ( - len(Json5("Hello World //added a EOL comment", 0).js5) == len("Hello World //added a EOL comment") + 4 - ), "Length of string not conserved when replacing comment" + assert len(Json5("Hello World //added a EOL comment", 0).js5) == len("Hello World //added a EOL comment") + 4, ( + "Length of string not conserved when replacing comment" + ) assert Json5("Hello//EOL comment", 0).js5 == "{ Hello }", "Comment not properly replaced" assert Json5("Hello#EOL comment", 0).js5 == "{ Hello }", "Comment not properly replaced" @@ -232,9 +232,9 @@ def test_json5_syntax(): "Hi": 1.0, "Ho": 2.0, }, "Simple dict expected. Second key without '" - assert Json5("{'Hello:@#%&/=?World':1}").to_py() == { - "Hello:@#%&/=?World": 1 - }, "Literal string keys should handle any character, including':' and comments" + assert Json5("{'Hello:@#%&/=?World':1}").to_py() == {"Hello:@#%&/=?World": 1}, ( + "Literal string keys should handle any character, including':' and comments" + ) js = Json5("{Start: {\n 'H':1,\n 99:{'e':11,'l':12}},\nLast:999}") assert js.to_py() == { diff --git a/tests/test_oscillator_fmu.py b/tests/test_oscillator_fmu.py index bf43528..b1d8707 100644 --- a/tests/test_oscillator_fmu.py +++ b/tests/test_oscillator_fmu.py @@ -28,9 +28,9 @@ def check_expected(value, expected, feature: str): def arrays_equal(res: tuple, expected: tuple, eps=1e-7): - assert len(res) == len( - expected - ), f"Tuples of different lengths cannot be equal. Found {len(res)} != {len(expected)}" + assert len(res) == len(expected), ( + f"Tuples of different lengths cannot be equal. Found {len(res)} != {len(expected)}" + ) for i, (x, y) in enumerate(zip(res, expected, strict=False)): assert abs(x - y) < eps, f"Element {i} not nearly equal in {x}, {y}" @@ -130,7 +130,7 @@ def test_oscillator_force_class(show): # _f = partial(force, ampl=1.0, omega=0.1) dt = 0.01 time = 0.0 - assert abs(2 * pi / sqrt(osc.k / osc.m) - 2 * pi) < 1e-9, f"Period should be {2*pi}" + assert abs(2 * pi / sqrt(osc.k / osc.m) - 2 * pi) < 1e-9, f"Period should be {2 * pi}" for _ in range(10000): osc.f = func(time) osc.do_step(time, dt) diff --git a/tests/test_osp_systemstructure.py b/tests/test_osp_systemstructure.py index e5ebc5e..b82b2ec 100644 --- a/tests/test_osp_systemstructure.py +++ b/tests/test_osp_systemstructure.py @@ -1,5 +1,6 @@ from pathlib import Path +import pytest from libcosimpy.CosimEnums import ( CosimVariableCausality, CosimVariableType, @@ -40,15 +41,15 @@ def test_osp_structure(): def test_system_structure_from_js5(): - osp_system_structure_from_js5(Path(__file__).parent / "data" / "crane_table.js5") + osp_system_structure_from_js5(Path(__file__).parent / "data" / "MobileCrane" / "crane_table.js5") if __name__ == "__main__": - # retcode = pytest.main(["-rA", "-v", __file__]) - # assert retcode == 0, f"Non-zero return code {retcode}" - import os + retcode = pytest.main(["-rA", "-v", __file__]) + assert retcode == 0, f"Non-zero return code {retcode}" + # import os - os.chdir(Path(__file__).parent / "test_working_directory") - test_system_structure() + # os.chdir(Path(__file__).parent / "test_working_directory") + # test_system_structure() # test_osp_structure() # test_system_structure_from_js5() diff --git a/tests/test_results.py b/tests/test_results.py index a73ff88..56dff7d 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -1,6 +1,8 @@ from datetime import datetime from pathlib import Path +import pytest + from sim_explorer.case import Cases, Results @@ -27,7 +29,7 @@ def test_add(): case = cases.case_by_name("base") res = Results(case=case) res._header_transform(tostring=True) - res.add(time=0.0, comp=0, typ=0, refs=[6], values=(9.81,)) + res.add(time=0.0, comp="bb", cvar="g", values=(9.81,)) # print( res.res.write( pretty_print=True)) assert res.res.jspath("$['0.0'].bb.g") == 9.81 @@ -51,7 +53,7 @@ def test_inspect(): assert cont["bb.x"]["len"] == 300 assert cont["bb.x"]["range"] == [0.01, 3.0] assert cont["bb.x"]["info"]["description"] == "3D Position of the ball in meters" - assert cont["bb.x"]["info"]["variables"] == (0, 1, 2), "ValueReferences" + assert cont["bb.x"]["info"]["refs"] == (0, 1, 2), "ValueReferences" def test_retrieve(): @@ -65,13 +67,12 @@ def test_retrieve(): if __name__ == "__main__": - # retcode = pytest.main(["-rA", "-v", __file__, "--show", "True"]) - # assert retcode == 0, f"Non-zero return code {retcode}" - import os - - os.chdir(Path(__file__).parent.absolute() / "test_working_directory") + retcode = pytest.main(["-rA", "-v", __file__, "--show", "True"]) + assert retcode == 0, f"Non-zero return code {retcode}" + # import os + # os.chdir(Path(__file__).parent.absolute() / "test_working_directory") # test_retrieve() # test_init() # test_add() - test_plot_time_series(show=True) + # test_plot_time_series(show=True) # test_inspect() diff --git a/tests/test_run_bouncingball0.py b/tests/test_run_bouncingball0.py index 6e234be..d659edd 100644 --- a/tests/test_run_bouncingball0.py +++ b/tests/test_run_bouncingball0.py @@ -6,44 +6,7 @@ from sim_explorer.case import Case, Cases from sim_explorer.json5 import Json5 -from sim_explorer.simulator_interface import SimulatorInterface - - -def expected_actions(case: Case, act: dict, expect: dict): - """Check whether a given action dict 'act' conforms to expectations 'expect', - where expectations are specified in human-readable form: - ('get/set', instance_name, type, (var_names,)[, (var_values,)]) - """ - sim = case.cases.simulator # the simulatorInterface - for time, actions in act.items(): - assert time in expect, f"time entry {time} not found in expected dict" - a_expect = expect[time] - for i, action in enumerate(actions): - msg = f"Case {case.name}({time})[{i}]" # , expect: {a_expect[i]}") - aname = { - "set_initial": "set0", - "set_variable_value": "set", - "get_variable_value": "get", - }[action.func.__name__] - assert aname == a_expect[i][0], f"{msg}. Erroneous action type {aname}" - # make sure that arguments 2.. are tuples - args = [None] * 5 - for k in range(2, len(action.args)): - if isinstance(action.args[k], tuple): - args[k] = action.args[k] - else: - args[k] = (action.args[k],) # type: ignore[call-overload] - arg = [ - sim.component_name_from_id(action.args[0]), - SimulatorInterface.pytype(action.args[1]), - tuple(sim.variable_name_from_ref(comp=action.args[0], ref=ref) for ref in args[2]), # type: ignore[attr-defined] - ] - for k in range(1, len(action.args)): - if k == 3: - assert len(a_expect[i]) == 5, f"{msg}. Need also a value argument in expect:{expect}" - assert args[3] == a_expect[i][4], f"{msg}. Erroneous value argument {action.args[3]}." - else: - assert arg[k] == a_expect[i][k + 1], f"{msg}. [{k}]: in {arg} != Expected: {a_expect[i]}" +from sim_explorer.system_interface_osp import SystemInterfaceOSP def expect_bounce_at(results: Json5, time: float, eps=0.02): @@ -53,6 +16,7 @@ def expect_bounce_at(results: Json5, time: float, eps=0.02): try: _t = float(t) if previous is not None: + print(results.jspath(f"$.['{t}'].bb.h"), previous[0]) falling = results.jspath(f"$.['{t}'].bb.h") < previous[0] # if falling != previous[1]: # print(f"EXPECT_bounce @{_t}: {previous[1]} -> {falling}") @@ -75,7 +39,8 @@ def test_step_by_step(): """Do the simulation step-by step, only using libcosimpy""" path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") assert path.exists(), "System structure file not found" - sim = SimulatorInterface(path) + sim = SystemInterfaceOSP(path) + sim.init_simulator() assert sim.simulator.real_initial_value(0, 6, 0.35), "Setting of 'e' did not work" for t in np.linspace(1, 1e9, 100): sim.simulator.simulate_until(t) @@ -92,21 +57,18 @@ def test_step_by_step_interface(): """Do the simulation step by step, using the simulatorInterface""" path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") assert path.exists(), "System structure file not found" - sim = SimulatorInterface(path) + sim = SystemInterfaceOSP(path) # Commented out as order of variables and models are not guaranteed in different OS # assert sim.components["bb"] == 0 # print(f"Variables: {sim.get_variables( 0, as_numbers = False)}") # assert sim.get_variables(0)["e"] == {"reference": 6, "type": 0, "causality": 1, "variability": 2} - sim.set_initial(0, 0, 6, 0.35) + sim.init_simulator() + sim.manipulator.slave_real_values(0, (6,), (0.35,)) for t in np.linspace(1, 1e9, 1): sim.simulator.simulate_until(t) - print(sim.get_variable_value(instance=0, typ=0, var_refs=(0, 1, 6))) + assert sim.observer.last_real_values(0, (0, 1, 6)) == [0.01, 0.99955855, 0.35] if t == int(0.11 * 1e9): - assert sim.get_variable_value(instance=0, typ=0, var_refs=(0, 1, 6)) == [ - 0.11, - 0.9411890500000001, - 0.35, - ] + assert sim.observer.last_real_values(0, (0, 1, 6)) == [0.11, 0.9411890500000001, 0.35] def test_run_cases(): @@ -119,68 +81,19 @@ def test_run_cases(): restitutionAndGravity = cases.case_by_name("restitutionAndGravity") gravity = cases.case_by_name("gravity") assert gravity - expected_actions( - case=gravity, - act=gravity.act_get, - expect={ - -1: [("get", "bb", float, ("h",))], - 0.0: [ - ("get", "bb", float, ("e",)), - ("get", "bb", float, ("g",)), - ("get", "bb", float, ("h",)), - ], - 1e9: [("get", "bb", float, ("v",))], - }, - ) - + assert gravity.act_get == {-1: [("h", "bb", (1,))], 1e9: [("v", "bb", (3,))]} assert base - expected_actions( - case=base, - act=base.act_set, - expect={ - 0: [ - ("set0", "bb", float, ("g",), (-9.81,)), - ("set0", "bb", float, ("e",), (1.0,)), - ("set0", "bb", float, ("h",), (1.0,)), - ] - }, - ) + assert base.act_set == {0: [("g", "bb", (5,), (-9.81,)), ("e", "bb", (6,), (1.0,)), ("h", "bb", (1,), (1.0,))]} assert restitution - expected_actions( - case=restitution, - act=restitution.act_set, - expect={ - 0: [ - ("set0", "bb", float, ("g",), (-9.81,)), - ("set0", "bb", float, ("e",), (0.5,)), - ("set0", "bb", float, ("h",), (1.0,)), - ] - }, - ) - + print("ACTIONS", restitution.act_set) + assert restitution.act_set == { + 0: [("g", "bb", (5,), (-9.81,)), ("e", "bb", (6,), (0.5,)), ("h", "bb", (1,), (1.0,))] + } assert restitutionAndGravity - expected_actions( - case=restitutionAndGravity, - act=restitutionAndGravity.act_set, - expect={ - 0: [ - ("set0", "bb", float, ("g",), (-1.5,)), - ("set0", "bb", float, ("e",), (0.5,)), - ("set0", "bb", float, ("h",), (1.0,)), - ] - }, - ) - expected_actions( - case=gravity, - act=gravity.act_set, - expect={ - 0: [ - ("set0", "bb", float, ("g",), (-1.5,)), - ("set0", "bb", float, ("e",), (1.0,)), - ("set0", "bb", float, ("h",), (1.0,)), - ] - }, - ) + assert restitutionAndGravity.act_set == { + 0: [("g", "bb", (5,), (-1.5,)), ("e", "bb", (6,), (0.5,)), ("h", "bb", (1,), (1.0,))] + } + assert gravity.act_set == {0: [("g", "bb", (5,), (-1.5,)), ("e", "bb", (6,), (1.0,)), ("h", "bb", (1,), (1.0,))]} print("Actions checked") case = cases.case_by_name("base") assert case is not None, "Case 'base' not found" @@ -213,33 +126,35 @@ def test_run_cases(): v_max = sqrt(2 * h0 * 9.81) # speed when hitting bottom # h_v = lambda v, g: 0.5 * v**2 / g # calculate height assert abs(h0 - 1.0) < 1e-2 - assert expect_bounce_at(results=res, time=t0, eps=0.02), f"Bounce: {t0} != {sqrt(2*h0/9.81)}" - assert expect_bounce_at(results=res, time=2 * t0, eps=0.02), f"No top point at {2*sqrt(2*h0/9.81)}" + assert expect_bounce_at(results=res, time=t0, eps=0.02), f"Bounce: {t0} != {sqrt(2 * h0 / 9.81)}" + assert expect_bounce_at(results=res, time=2 * t0, eps=0.02), f"No top point at {2 * sqrt(2 * h0 / 9.81)}" - cases.simulator.reset() + cases.simulator.init_simulator() print("Run restitution") cases.run_case(name="restitution", dump="results_restitution") _case = cases.case_by_name("restitution") assert _case is not None res = _case.res.res - assert expect_bounce_at(results=res, time=sqrt(2 * h0 / 9.81), eps=0.02), f"No bounce at {sqrt(2*h0/9.81)}" + assert expect_bounce_at(results=res, time=sqrt(2 * h0 / 9.81), eps=0.02), f"No bounce at {sqrt(2 * h0 / 9.81)}" assert expect_bounce_at( res, sqrt(2 * h0 / 9.81) + 0.5 * v_max / 9.81, eps=0.02 ) # restitution is a factor on speed at bounce - cases.simulator.reset() + cases.simulator.init_simulator() print("Run gravity", cases.run_case("gravity", "results_gravity")) - assert expect_bounce_at(res, sqrt(2 * h0 / 1.5), eps=0.02), f"No bounce at {sqrt(2*h0/9.81)}" - cases.simulator.reset() + assert expect_bounce_at(res, sqrt(2 * h0 / 1.5), eps=0.02), f"No bounce at {sqrt(2 * h0 / 9.81)}" + cases.simulator.init_simulator() print( "Run restitutionAndGravity", cases.run_case("restitutionAndGravity", "results_restitutionAndGravity"), ) - assert expect_bounce_at(res, sqrt(2 * h0 / 1.5), eps=0.02), f"No bounce at {sqrt(2*h0/9.81)}" + assert expect_bounce_at(res, sqrt(2 * h0 / 1.5), eps=0.02), f"No bounce at {sqrt(2 * h0 / 9.81)}" assert expect_bounce_at(res, sqrt(2 * h0 / 1.5) + 0.5 * sqrt(2 * h0 / 1.5), eps=0.4) - cases.simulator.reset() + cases.simulator.init_simulator() if __name__ == "__main__": retcode = pytest.main(["-rA", "-v", __file__]) assert retcode == 0, f"Non-zero return code {retcode}" # test_run_cases() + # test_step_by_step() + # test_step_by_step_interface() diff --git a/tests/test_run_mobilecrane.py b/tests/test_run_mobilecrane.py index 8c9354a..26a7663 100644 --- a/tests/test_run_mobilecrane.py +++ b/tests/test_run_mobilecrane.py @@ -10,11 +10,15 @@ from sim_explorer.case import Case, Cases from sim_explorer.json5 import Json5 -from sim_explorer.simulator_interface import SimulatorInterface +from sim_explorer.system_interface_osp import SystemInterfaceOSP @pytest.fixture(scope="session") def mobile_crane_fmu(): + return _mobile_crane_fmu() + + +def _mobile_crane_fmu(): return Path(__file__).parent / "data" / "MobileCrane" / "MobileCrane.fmu" @@ -84,9 +88,9 @@ def set_initial(name: str, value: float, slave: int = 0): assert sim.slave_variables(slave)[i].reference == i assert sim.slave_variables(slave)[i].type == 0 found_expected[k] = True - assert ( - False not in found_expected - ), f"Not all expected names were found: {expected_names[found_expected.index(False)]}" + assert False not in found_expected, ( + f"Not all expected names were found: {expected_names[found_expected.index(False)]}" + ) assert set_initial("pedestal_boom[0]", 3.0) assert set_initial("boom_boom[0]", 8.0) assert set_initial("boom_boom[1]", 0.7854) @@ -110,15 +114,15 @@ def set_initial(name: str, value: float, slave: int = 0): sim.simulate_until(step_count * 1e9) -# @pytest.mark.skip("Alternative step-by step, using SimulatorInterface and Cases") +# @pytest.mark.skip("Alternative step-by step, using SystemInterfaceOSP and Cases") def test_step_by_step_cases(mobile_crane_fmu): - sim: SimulatorInterface + sim: SystemInterfaceOSP cosim: CosimExecution def get_ref(name: str): - variable = cases.simulator.get_variables(0, name) + variable = cases.simulator.variables(0)[name] assert len(variable), f"Variable {name} not found" - return next(iter(variable.values()))["reference"] + return variable["reference"] def set_initial(name: str, value: float, slave: int = 0): for idx in range(cosim.num_slave_variables(slave_index=slave)): @@ -126,16 +130,26 @@ def set_initial(name: str, value: float, slave: int = 0): return cosim.real_initial_value(slave_index=slave, variable_reference=idx, value=value) def initial_settings(): - cases.simulator.set_initial(0, 0, get_ref("pedestal_boom[0]"), 3.0) - cases.simulator.set_initial(0, 0, get_ref("boom_boom[0]"), 8.0) - cases.simulator.set_initial(0, 0, get_ref("boom_boom[1]"), 0.7854) - cases.simulator.set_initial(0, 0, get_ref("rope_boom[0]"), 1e-6) - cases.simulator.set_initial(0, 0, get_ref("dLoad"), 50.0) + assert isinstance(cases.simulator, SystemInterfaceOSP) + cases.simulator.manipulator.slave_real_values( + 0, + ( + get_ref("pedestal_boom[0]"), + get_ref("boom_boom[0]"), + get_ref("boom_boom[1]"), + get_ref("rope_boom[0]"), + get_ref("dLoad"), + ), + (3.0, 8.0, 0.7854, 1e-6, 50.0), + ) system = Path(Path(__file__).parent / "data" / "MobileCrane" / "OspSystemStructure.xml") assert system.exists(), f"OspSystemStructure file {system} not found" - sim = SimulatorInterface(system) - assert sim.get_components() == {"mobileCrane": 0}, f"Found component {sim.get_components()}" + sim = SystemInterfaceOSP(system) + sim.init_simulator() + print("COMP", {k: v for k, v in sim.components.items()}) + expected = {k: v for k, v in sim.components.items()} + assert isinstance(expected["mobileCrane"], str), f"Found components {expected}" path = Path(Path(__file__).parent, "data/MobileCrane/MobileCrane.cases") assert path.exists(), "Cases file not found" @@ -153,11 +167,12 @@ def initial_settings(): "name", "description", "modelFile", + "simulator", "logLevel", "timeUnit", "variables", - ] - cases = Cases(path, sim) + ], f"Found: {list(js.jspath('$.header', dict).keys())}" + cases = Cases(path) print("INFO", cases.info()) static = cases.case_by_name("static") assert static is not None @@ -167,32 +182,39 @@ def initial_settings(): "r[0]": 7.657, "load": 1000, } - assert static.act_get[-1][0].args == ( - 0, - 0, - (10, 11, 12), - ), f"Step action arguments {static.act_get[-1][0].args}" - assert sim.get_variable_value(0, 0, (10, 11, 12)) == [ - 0.0, - 0.0, - 0.0, - ], "Initial value of T" + print("ACT", static.act_get[-1][0]) + assert static.act_get[-1][0] == ("T", "mobileCrane", (10, 11, 12)) + sim.init_simulator() + assert sim.observer.last_real_values(0, (10, 11, 12)) == [0.0, 0.0, 0.0], "Initial value of T" # msg = f"SET actions argument: {static.act_set[0][0].args}" # assert static.act_set[0][0].args == (0, 0, (13, 15), (3, 1.5708)), msg # sim.set_initial(0, 0, (13, 15), (3, 0)) # assert sim.get_variable_value(0, 0, (13, 15)) == [3.0, 0.0], "Initial value of T" print(f"Special: {static.special}") - print("Actions SET") - for t in static.act_set: - print(f" Time {t}: ") - for a in static.act_set[t]: - print(" ", static.str_act(a)) - print("Actions GET") - for t in static.act_get: - print(f" Time {t}: ") - for a in static.act_get[t]: - print(" ", static.str_act(a)) + assert static.act_set == { + 0: [ + ("p", "mobileCrane", (18, 20), (3.0, 1.570796)), + ("b", "mobileCrane", (34, 35), (8.0, 45.0)), + ("r", "mobileCrane", (50,), (7.657,)), + ("df_dt", "mobileCrane", (7, 8), (0.0, 0.0)), + ("dp_dt", "mobileCrane", (25,), (0.0,)), + ("db_dt", "mobileCrane", (40,), (0.0,)), + ("dr_dt", "mobileCrane", (58,), (0.0,)), + ("v", "mobileCrane", (7, 8), (0.0, 0.0)), + ("load", "mobileCrane", (16,), (1000.0,)), + ] + } + assert static.act_get == { + -1: [ + ("T", "mobileCrane", (10, 11, 12)), + ("x_pedestal", "mobileCrane", (21, 22, 23)), + ("x_boom", "mobileCrane", (37, 38, 39)), + ("x_load", "mobileCrane", (53, 54, 55)), + ] + } + cases.simulator.init_simulator() + assert isinstance(cases.simulator, SystemInterfaceOSP) cosim = cases.simulator.simulator slave = cosim.slave_index_from_instance_name("mobileCrane") assert slave == 0, f"Slave index should be '0', found {slave}" @@ -216,6 +238,7 @@ def initial_settings(): # for idx in range( cosim.num_slave_variables(slave)): # print(f"{cosim.slave_variables(slave)[idx].name.decode()}: {observer.last_real_values(slave, [idx])}") + assert isinstance(cases.simulator, SystemInterfaceOSP) initial_settings() manipulator = cases.simulator.manipulator assert isinstance(manipulator, CosimManipulator) @@ -250,15 +273,15 @@ def initial_settings(): # cases.simulator.set_variable_value(0, 0, (get_ref("boom_angularVelocity"),), (0.7,)) -# @pytest.mark.skip("Alternative only using SimulatorInterface") +# @pytest.mark.skip("Alternative only using SystemInterfaceOSP") def test_run_basic(): path = Path(Path(__file__).parent / "data" / "MobileCrane" / "OspSystemStructure.xml") assert path.exists(), "System structure file not found" - sim = SimulatorInterface(path) + sim = SystemInterfaceOSP(path) + sim.init_simulator() sim.simulator.simulate_until(1e9) -# @pytest.mark.skip("So far not working. Need to look into that: Run all cases defined in MobileCrane.cases") def test_run_cases(): path = Path(Path(__file__).parent / "data" / "MobileCrane" / "MobileCrane.cases") # system_structure = Path(Path(__file__).parent, "data/MobileCrane/OspSystemStructure.xml") @@ -269,11 +292,12 @@ def test_run_cases(): # print(v, info) static = cases.case_by_name("static") assert static is not None - assert static.act_get[-1][0].func.__name__ == "get_variable_value" - assert static.act_get[-1][0].args == (0, 0, (10, 11, 12)) - assert static.act_get[-1][1].args == (0, 0, (21, 22, 23)) - assert static.act_get[-1][2].args == (0, 0, (37, 38, 39)) - assert static.act_get[-1][3].args == (0, 0, (53, 54, 55)) + assert static.act_get[-1] == [ + ("T", "mobileCrane", (10, 11, 12)), + ("x_pedestal", "mobileCrane", (21, 22, 23)), + ("x_boom", "mobileCrane", (37, 38, 39)), + ("x_load", "mobileCrane", (53, 54, 55)), + ] print("Running case 'base'...") case = cases.case_by_name("base") @@ -293,7 +317,7 @@ def test_run_cases(): print("RES(1.0)", res.jspath("$['1.0'].mobileCrane")) assert is_nearly_equal(res.jspath("$['1.0'].mobileCrane.x_pedestal"), [0.0, 0.0, 3.0]) x_load = res.jspath("$['1.0'].mobileCrane.x_load") - print(f"x_load: {x_load} <-> {[0, 8/sqrt(2),0]}") + print(f"x_load: {x_load} <-> {[0, 8 / sqrt(2), 0]}") # print("Running case 'static'...") @@ -314,5 +338,5 @@ def test_run_cases(): # test_read_cases() # test_step_by_step_cosim(_mobile_crane_fmu()) # test_step_by_step_cases(_mobile_crane_fmu()) - # test_run_basic(_mobile_crane_fmu()) - # test_run_cases(_mobile_crane_fmu()) + # test_run_basic() + # test_run_cases() diff --git a/tests/test_run_simpletable.py b/tests/test_run_simpletable.py index 52e3c70..f06ef13 100644 --- a/tests/test_run_simpletable.py +++ b/tests/test_run_simpletable.py @@ -18,3 +18,4 @@ def test_run_casex(): if __name__ == "__main__": retcode = pytest.main(["-rA", "-v", __file__]) assert retcode == 0, f"Return code {retcode}" + # test_run_casex() diff --git a/tests/test_simulator_interface.py b/tests/test_simulator_interface.py deleted file mode 100644 index d07859c..0000000 --- a/tests/test_simulator_interface.py +++ /dev/null @@ -1,134 +0,0 @@ -from pathlib import Path - -import pytest -from libcosimpy.CosimExecution import CosimExecution - -from sim_explorer.simulator_interface import SimulatorInterface -from sim_explorer.utils.misc import match_with_wildcard - - -def test_match_with_wildcard(): - assert match_with_wildcard("Hello World", "Hello World"), "Match expected" - assert not match_with_wildcard("Hello World", "Helo World"), "No match expected" - assert match_with_wildcard("*o World", "Hello World"), "Match expected" - assert not match_with_wildcard("*o W*ld", "Hello Word"), "No match expected" - assert match_with_wildcard("*o W*ld", "Hello World"), "Two wildcard matches expected" - - -def test_pytype(): - assert SimulatorInterface.pytype("REAL", "2.3") == 2.3, "Expected 2.3 as float type" - assert SimulatorInterface.pytype("Integer", "99") == 99, "Expected 99 as int type" - assert SimulatorInterface.pytype("Boolean", "fmi2True"), "Expected True as bool type" - assert not SimulatorInterface.pytype("Boolean", "fmi2false"), "Expected True as bool type" - assert SimulatorInterface.pytype("String", "fmi2False") == "fmi2False", "Expected fmi2False as str type" - with pytest.raises(ValueError) as err: - SimulatorInterface.pytype("Real", "fmi2False") - assert str(err.value).startswith("could not convert string to float:"), "No error raised as expected" - assert SimulatorInterface.pytype(0) is float - assert SimulatorInterface.pytype(1) is int - assert SimulatorInterface.pytype(2) is str - assert SimulatorInterface.pytype(3) is bool - assert SimulatorInterface.pytype(1, 2.3) == 2 - - -def test_component_variable_name(): - path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") - system = SimulatorInterface(str(path), name="BouncingBall") - """ - Slave order is not guaranteed in different OS - assert 1 == system.simulator.slave_index_from_instance_name("bb") - assert 0 == system.simulator.slave_index_from_instance_name("bb2") - assert 2 == system.simulator.slave_index_from_instance_name("bb3") - assert system.components["bb"] == 0, f"Error in unique model index. Found {system.components['bb']}" - """ - assert system.variable_name_from_ref("bb", 0) == "time" - assert system.variable_name_from_ref("bb", 1) == "h" - assert system.variable_name_from_ref("bb", 2) == "der(h)" - assert system.variable_name_from_ref("bb", 3) == "v" - assert system.variable_name_from_ref("bb", 4) == "der(v)" - assert system.variable_name_from_ref("bb", 5) == "g" - assert system.variable_name_from_ref("bb", 6) == "e" - assert system.variable_name_from_ref("bb", 7) == "v_min" - assert system.variable_name_from_ref("bb", 8) == "" - - -def test_default_initial(): - print("DIR", dir(SimulatorInterface)) - assert SimulatorInterface.default_initial(0, 0) == 3, f"Found {SimulatorInterface.default_initial( 0, 0)}" - assert SimulatorInterface.default_initial(1, 0) == 3, f"Found {SimulatorInterface.default_initial( 1, 0)}" - assert SimulatorInterface.default_initial(2, 0) == 0, f"Found {SimulatorInterface.default_initial( 2, 0)}" - assert SimulatorInterface.default_initial(3, 0) == 3, f"Found {SimulatorInterface.default_initial( 3, 0)}" - assert SimulatorInterface.default_initial(4, 0) == 0, f"Found {SimulatorInterface.default_initial( 4, 0)}" - assert SimulatorInterface.default_initial(5, 0) == 3, f"Found {SimulatorInterface.default_initial( 5, 0)}" - assert SimulatorInterface.default_initial(1, 1) == 0, f"Found {SimulatorInterface.default_initial( 1, 1)}" - assert SimulatorInterface.default_initial(1, 2) == 0, f"Found {SimulatorInterface.default_initial( 1, 1)}" - assert SimulatorInterface.default_initial(1, 3) == 3, f"Found {SimulatorInterface.default_initial( 1, 1)}" - assert SimulatorInterface.default_initial(1, 4) == 3, f"Found {SimulatorInterface.default_initial( 1, 1)}" - assert SimulatorInterface.default_initial(2, 0) == 0, f"Found {SimulatorInterface.default_initial( 2, 0)}" - assert SimulatorInterface.default_initial(5, 4) == 3, f"Found {SimulatorInterface.default_initial( 5, 4)}" - assert SimulatorInterface.default_initial(3, 2) == 2, f"Found {SimulatorInterface.default_initial( 3, 2)}" - assert SimulatorInterface.default_initial(4, 2) == 2, f"Found {SimulatorInterface.default_initial( 4, 2)}" - - -def test_simulator_from_system_structure(): - """SimulatorInterface from OspSystemStructure.xml""" - path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") - system = SimulatorInterface(str(path), name="BouncingBall") - assert system.name == "BouncingBall", f"System.name should be BouncingBall. Found {system.name}" - assert "bb" in system.components, f"Instance name 'bb' expected. Found instances {system.components}" - # assert system.get_models()[0] == 0, f"Component model {system.get_models()[0]}" - assert "bb" in system.get_components() - - -def test_simulator_reset(): - """SimulatorInterface from OspSystemStructure.xml""" - path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") - system = SimulatorInterface(str(path), name="BouncingBall") - system.simulator.simulate_until(1e9) - # print("STATUS", system.simulator.status()) - assert system.simulator.status().current_time == 1e9 - system.reset() - assert system.simulator.status().current_time == 0 - - -def test_simulator_instantiated(): - """Start with an instantiated simulator.""" - path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") - sim = CosimExecution.from_osp_config_file(str(path)) - # print("STATUS", sim.status()) - simulator = SimulatorInterface( - system=str(path), - name="BouncingBall System", - description="Testing info retrieval from simulator (without OspSystemStructure)", - simulator=sim, - ) - # simulator.check_instances_variables() - assert len(simulator.components) == 3, "Three instantiated (identical) components" - variables = simulator.get_variables("bb") - assert variables["g"] == { - "reference": 5, - "type": 0, - "causality": 1, - "variability": 1, - } - assert simulator.allowed_action("set", "bb", "g", 0) - assert not simulator.allowed_action("set", "bb", "g", 100) - assert simulator.message.startswith("Variable g, causality PARAMETER,") - assert simulator.allowed_action("set", "bb", "e", 100), simulator.message - assert simulator.allowed_action("set", "bb", "h", 0), simulator.message - assert not simulator.allowed_action("set", "bb", "h", 100), simulator.message - assert simulator.allowed_action("set", "bb", "der(h)", 0), simulator.message - assert not simulator.allowed_action("set", "bb", "der(h)", 100), simulator.message - assert simulator.allowed_action("set", "bb", "v", 0), simulator.message - assert not simulator.allowed_action("set", "bb", "v", 100), simulator.message - assert simulator.allowed_action("set", "bb", "der(v)", 0), simulator.message - assert not simulator.allowed_action("set", "bb", "der(v)", 100), simulator.message - assert not simulator.allowed_action("set", "bb", "v_min", 0), simulator.message - assert simulator.allowed_action("set", "bb", (1, 3), 0), simulator.message # combination of h,v - assert not simulator.allowed_action("set", "bb", (1, 3), 100), simulator.message # combination of h,v - - -if __name__ == "__main__": - retcode = pytest.main(["-rA", "-v", __file__]) - assert retcode == 0, f"Return code {retcode}" - # test_component_variable_name() diff --git a/tests/test_system_interface.py b/tests/test_system_interface.py new file mode 100644 index 0000000..5dd3dfd --- /dev/null +++ b/tests/test_system_interface.py @@ -0,0 +1,94 @@ +from enum import Enum +from pathlib import Path + +import pytest + +from sim_explorer.system_interface import SystemInterface + + +def test_read_system_structure(): + for file in ("crane_table.js5", "crane_table.xml"): + s = SystemInterface.read_system_structure(Path(__file__).parent / "data" / "MobileCrane" / file) + # print(file, s) + assert s["header"]["version"] == "0.1", f"Found {s['header']['version']}" + assert s["header"]["xmlns"] == "http://opensimulationplatform.com/MSMI/OSPSystemStructure" + assert s["header"]["StartTime"] == 0.0 + assert s["header"]["Algorithm"] == "fixedStep" + assert s["header"]["BaseStepSize"] == 0.01 + assert len(s["Simulators"]) == 2 + assert ( + s["Simulators"]["simpleTable"]["source"] + == Path(__file__).parent / "data" / "SimpleTable" / "SimpleTable.fmu" + ) + assert s["Simulators"]["mobileCrane"]["pedestal.pedestalMass"] == 5000.0 + + +def test_pytype(): + assert SystemInterface.pytype("Real") is float + assert SystemInterface.pytype("Enumeration") is Enum + assert SystemInterface.pytype("Real", 1) == 1.0 + assert SystemInterface.pytype("Integer", 1) == 1 + assert SystemInterface.pytype("String", 1.0) == "1.0" + + +def test_interface(): + sys = SystemInterface(Path(__file__).parent / "data" / "MobileCrane" / "crane_table.js5") + # manually adding another SimpleTable to the system + sys._models["SimpleTable"]["components"].append("simpleTable2") + sys.components.update({"simpleTable2": "SimpleTable"}) + assert isinstance(sys, SystemInterface) + assert list(sys.components.keys()) == ["simpleTable", "mobileCrane", "simpleTable2"] + assert len(sys.models) == 2 + assert tuple(sys.models.keys()) == ("SimpleTable", "MobileCrane"), f"Found:{sys.models}" + m = sys.match_components("simple*") + assert m[0] == "SimpleTable", f"Found {m[0]}" + assert m[1] == ("simpleTable", "simpleTable2") + for k in sys.components.keys(): + assert sys.component_name_from_id(sys.component_id_from_name(k)) == k + vars = sys.variables("simpleTable") + assert vars["interpolate"]["causality"] == "parameter" + assert vars["interpolate"]["type"] is bool, f"Found {vars['interpolate']['type']}" + assert sys.match_variables("simpleTable", "outs") == (("outs[0]", 0), ("outs[1]", 1), ("outs[2]", 2)) + assert sys.match_variables("simpleTable", "interpolate") == (("interpolate", 3),) + assert sys.variable_name_from_ref("simpleTable", 2) == "outs[2]" + assert sys.variable_name_from_ref("simpleTable", 100) == "", "Not existent" + default = SystemInterface.default_initial("output", "fixed") + assert default == -5, f"Found:{default}" + assert SystemInterface.default_initial("parameter", "fixed") == "exact" + assert sys.allowed_action("Set", "simpleTable", "interpolate", 0) + assert sys.allowed_action("Get", "simpleTable", "outs", 0) + # assert sys.message, "Variable outs of component simpleTable was not found" + + with pytest.raises(NotImplementedError) as err: + sys.do_action(0.0, (0, 1), float) + assert str(err.value) == "The method 'do_action()' cannot be used in SystemInterface" + with pytest.raises(NotImplementedError) as err: + _ = sys.action_step((0, 1), float) + assert str(err.value) == "The method 'action_step()' cannot be used in SystemInterface" + with pytest.raises(NotImplementedError) as err: + _ = sys.init_simulator() + assert str(err.value) == "The method 'init_simulator()' cannot be used in SystemInterface" + with pytest.raises(NotImplementedError) as err: + _ = sys.run_until(9.9) + assert str(err.value) == "The method 'run_until()' cannot be used in SystemInterface" + + +def test_update_refs_values(): + refs, vals = SystemInterface.update_refs_values((1, 3, 5, 7), (1, 5), (1.0, 5.0), (3, 5), (3.1, 5.1)) + assert refs == (1, 3, 5) + assert vals == (1.0, 3.1, 5.1) + with pytest.raises(ValueError) as err: + refs, vals = SystemInterface.update_refs_values((1, 3, 5, 7), (1, 5), (1.0, 5.0), (3, 6), (3.1, 5.1)) + assert str(err.value) == "tuple.index(x): x not in tuple" + refs, vals = SystemInterface.update_refs_values((1, 3, 5, 7), (1, 3), (1.0, 3.0), (5, 7), (5.1, 7.1)) + assert refs == (1, 3, 5, 7) + assert vals == (1.0, 3.0, 5.1, 7.1) + + +if __name__ == "__main__": + retcode = pytest.main(["-rA", "-v", __file__]) + assert retcode == 0, f"Non-zero return code {retcode}" + # test_read_system_structure() + # test_pytype() + # test_interface() + # test_update_refs_values() diff --git a/tests/test_system_interface_osp.py b/tests/test_system_interface_osp.py new file mode 100644 index 0000000..d971770 --- /dev/null +++ b/tests/test_system_interface_osp.py @@ -0,0 +1,193 @@ +from pathlib import Path + +import pytest +from libcosimpy.CosimExecution import CosimExecution +from libcosimpy.CosimManipulator import CosimManipulator +from libcosimpy.CosimObserver import CosimObserver + +from sim_explorer.system_interface_osp import SystemInterfaceOSP +from sim_explorer.utils.misc import match_with_wildcard + + +def test_match_with_wildcard(): + assert match_with_wildcard("Hello World", "Hello World"), "Match expected" + assert not match_with_wildcard("Hello World", "Helo World"), "No match expected" + assert match_with_wildcard("*o World", "Hello World"), "Match expected" + assert not match_with_wildcard("*o W*ld", "Hello Word"), "No match expected" + assert match_with_wildcard("*o W*ld", "Hello World"), "Two wildcard matches expected" + + +def test_pytype(): + assert SystemInterfaceOSP.pytype("REAL", "2.3") == 2.3, "Expected 2.3 as float type" + assert SystemInterfaceOSP.pytype("Integer", "99") == 99, "Expected 99 as int type" + assert SystemInterfaceOSP.pytype("Boolean", "fmi2True"), "Expected True as bool type" + assert not SystemInterfaceOSP.pytype("Boolean", "fmi2false"), "Expected True as bool type" + assert SystemInterfaceOSP.pytype("String", "fmi2False") == "fmi2False", "Expected fmi2False as str type" + with pytest.raises(ValueError) as err: + SystemInterfaceOSP.pytype("Real", "fmi2False") + assert str(err.value).startswith("could not convert string to float:"), "No error raised as expected" + assert SystemInterfaceOSP.pytype("Real", 0) == 0.0 + assert SystemInterfaceOSP.pytype("Integer", 1) == 1 + assert SystemInterfaceOSP.pytype("String", 2) == "2" + assert SystemInterfaceOSP.pytype("Boolean", 3) + + +def test_component_variable_name(): + path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") + system = SystemInterfaceOSP(path, name="BouncingBall") + """ + Slave order is not guaranteed in different OS + assert 1 == system.simulator.slave_index_from_instance_name("bb") + assert 0 == system.simulator.slave_index_from_instance_name("bb2") + assert 2 == system.simulator.slave_index_from_instance_name("bb3") + assert system.components["bb"] == 0, f"Error in unique model index. Found {system.components['bb']}" + """ + assert system.variable_name_from_ref("bb", 0) == "time" + assert system.variable_name_from_ref("bb", 1) == "h" + assert system.variable_name_from_ref("bb", 2) == "der(h)" + assert system.variable_name_from_ref("bb", 3) == "v" + assert system.variable_name_from_ref("bb", 4) == "der(v)" + assert system.variable_name_from_ref("bb", 5) == "g" + assert system.variable_name_from_ref("bb", 6) == "e" + assert system.variable_name_from_ref("bb", 7) == "v_min" + assert system.variable_name_from_ref("bb", 8) == "" + + +def test_default_initial(): + def di(var: str, caus: str, expected: str | int | tuple, only_default: bool = True): + res = SystemInterfaceOSP.default_initial(caus, var, only_default) + assert res == expected, f"default_initial({var}, {caus}): Found {res} but expected {expected}" + + di("constant", "parameter", -1) + di("constant", "calculated_parameter", -1) + di("constant", "input", -1) + di("constant", "output", "exact") + di("constant", "local", "exact") + di("constant", "independent", -3) + di("fixed", "parameter", "exact") + di("fixed", "calculated_parameter", "calculated") + di("fixed", "local", "calculated") + di("fixed", "input", -4) + di("tunable", "parameter", "exact") + di("tunable", "calculated_parameter", "calculated") + di("tunable", "output", -5) + di("tunable", "local", "calculated") + di("tunable", "input", -4) + di("discrete", "calculated_parameter", -2) + di("discrete", "input", 5) + di("discrete", "output", "calculated") + di("discrete", "local", "calculated") + di("continuous", "calculated_parameter", -2) + di("continuous", "independent", 15) + di("discrete", "output", ("calculated", "exact", "approx"), False) + + +def test_simulator_from_system_structure(): + """SystemInterfaceOSP from OspSystemStructure.xml""" + path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") + system = SystemInterfaceOSP(str(path), name="BouncingBall") + assert system.name == "BouncingBall", f"System.name should be BouncingBall. Found {system.name}" + assert "bb" in system.components, f"Instance name 'bb' expected. Found instances {list(system.components.keys())}" + assert len(system.components) == 3 + assert len(system.models) == 1 + assert "BouncingBall" in system.models + # system.check_instances_variables() + variables = system.variables("bb") + # print(f"g: {variables['g']}") + assert variables["g"]["reference"] == 5 + assert variables["g"]["type"] is float + assert variables["g"]["causality"] == "parameter" + assert variables["g"]["variability"] == "fixed" + + assert system.allowed_action("set", "bb", "g", 0) + assert not system.allowed_action("set", "bb", "g", 100) + assert system.message == "Change of g at communication point" + assert system.allowed_action("set", "bb", "e", 100), system.message + assert system.allowed_action("set", "bb", "h", 0), system.message + assert not system.allowed_action("set", "bb", "h", 100), system.message + assert not system.allowed_action("set", "bb", "der(h)", 0), system.message + assert not system.allowed_action("set", "bb", "der(h)", 100), system.message + assert system.allowed_action("set", "bb", "v", 0), system.message + assert not system.allowed_action("set", "bb", "v", 100), system.message + assert not system.allowed_action("set", "bb", "der(v)", 0), system.message + assert not system.allowed_action("set", "bb", "der(v)", 100), system.message + assert system.allowed_action("set", "bb", "v_min", 0), system.message + assert system.allowed_action("set", "bb", (1, 3), 0), system.message # combination of h,v + assert not system.allowed_action("set", "bb", (1, 3), 100), system.message # combination of h,v + + +def test_simulator_reset(): + """SystemInterfaceOSP from OspSystemStructure.xml""" + path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") + system = SystemInterfaceOSP(str(path), name="BouncingBall") + assert system.init_simulator(), f"Simulator initialization failed {system.simulator.status()}" + assert system.simulator.status().current_time == 0.0 + h0, g0 = (9.9, -4.81) + system.simulator.real_initial_value(0, 1, h0) # initial height h + system.simulator.real_initial_value(0, 5, g0) # g + assert system.observer.last_real_values(0, (1, 5)) == [0.0, 0.0], "Values only when the simulation starts!" + system.simulator.simulate_until(1e9) + assert system.simulator.status().current_time == 1e9 + values = system.observer.last_real_values(0, (1, 5)) + assert values[1] == g0, "Initial values set now" + assert abs(values[0] - (h0 + 0.5 * g0 * 1.0 * 1.0)) < 1e-2, "Height calculated (not very accurate!)" + system.manipulator.slave_real_values(0, (5,), (0.0,)) # zero gravity + system.simulator.simulate_until(2e9) + assert system.simulator.status().current_time == 2e9 + values = system.observer.last_real_values(0, (1, 5)) + assert values[1] == 0.0 + assert abs(values[0] - (h0 + 3 / 2 * g0 * 1.0 * 1.0)) < 1e-2, "No acceleration in second step" + # reset and start simulator with new values + assert system.init_simulator(), f"Simulator resetting failed {system.simulator.status()}" + assert system.simulator.status().current_time == 0 + h0, g0 = (19.9, -2.81) + system.simulator.real_initial_value(0, 1, h0) # initial height h + system.simulator.real_initial_value(0, 5, g0) # g + assert system.observer.last_real_values(0, (1, 5)) == [0.0, 0.0], "Values only when the simulation starts!" + system.simulator.simulate_until(1e9) + assert system.simulator.status().current_time == 1e9 + values = system.observer.last_real_values(0, (1, 5)) + assert values[1] == g0, "Initial values set now" + assert abs(values[0] - (h0 + 0.5 * g0 * 1.0 * 1.0)) < 1e-2, "Height calculated (not very accurate!)" + + +def test_simulator_instantiated(): + """Start with an instantiated simulator.""" + path = Path(Path(__file__).parent, "data/BouncingBall0/OspSystemStructure.xml") + sim = CosimExecution.from_osp_config_file(str(path)) + assert sim.status().current_time == 0 + system = SystemInterfaceOSP( + structure_file=str(path), + name="BouncingBall System", + description="Testing info retrieval from simulator (without OspSystemStructure)", + log_level="warning", + ) + assert isinstance(system, SystemInterfaceOSP) + # not yet initialized: + with pytest.raises(AttributeError) as _: + assert isinstance(system.manipulator, CosimManipulator) + with pytest.raises(AttributeError) as _: + assert isinstance(system.observer, CosimObserver) + assert system.init_simulator() + assert isinstance(system.manipulator, CosimManipulator), "Ok now" + h0, g0 = (9.9, -4.81) + system.simulator.real_initial_value(0, 1, h0) # initial height h + system.simulator.real_initial_value(0, 5, g0) # g + assert system.observer.last_real_values(0, (1, 5)) == [0.0, 0.0] + system.run_until(1e9) + assert system.simulator.status().current_time == int(1e9), f"STATUS: {system.simulator.status()}" + values = system.observer.last_real_values(0, (1, 5)) + values = system.observer.last_real_values(0, (1, 5)) + assert values[1] == g0, "Initial values set now" + assert abs(values[0] - (h0 + 0.5 * g0 * 1.0 * 1.0)) < 1e-2, "Height calculated (not very accurate!)" + + +if __name__ == "__main__": + retcode = pytest.main(["-rA", "-v", __file__]) + assert retcode == 0, f"Return code {retcode}" + # test_pytype() + # test_component_variable_name() + # test_default_initial() + # test_simulator_from_system_structure() + # test_simulator_reset() + # test_simulator_instantiated() diff --git a/tests/test_utils.py b/tests/test_utils.py index 32ddae1..017f4b6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -18,9 +18,9 @@ def test_relative_path(simexp): cases = simexp / "tests" / "data" / "BouncingBall3D" / "BouncingBall3D.cases" res = simexp / "tests" / "data" / "BouncingBall3D" / "test_results" cases0 = simexp / "tests" / "data" / "BouncingBall0" / "BouncingBall.cases" - assert relative_path(cases, res) == "./BouncingBall3D.cases", f"Found {relative_path( cases, res)}" + assert relative_path(cases, res) == "./BouncingBall3D.cases", f"Found {relative_path(cases, res)}" rel0 = relative_path(cases, cases0) - assert rel0 == "../../BouncingBall3D/BouncingBall3D.cases", f"Found {relative_path( cases, cases0)}" + assert rel0 == "../../BouncingBall3D/BouncingBall3D.cases", f"Found {relative_path(cases, cases0)}" expected = simexp / "tests" / "data" / "BouncingBall3D" / "BouncingBall3D.cases" found = get_path("BouncingBall3D.cases", res.parent)