diff --git a/ymmsl/conversion/convert_v0_1_to_v0_2.py b/ymmsl/conversion/convert_v0_1_to_v0_2.py index 230a22a..d35079a 100644 --- a/ymmsl/conversion/convert_v0_1_to_v0_2.py +++ b/ymmsl/conversion/convert_v0_1_to_v0_2.py @@ -134,7 +134,7 @@ def convert_model(model: v0_1.ModelReference) -> v0_2.Model: conduits = list(map(convert_conduit, model.conduits)) infer_ports(components, conduits) return v0_2.Model( - str(model.name), None, description, None, components, conduits + str(model.name), None, description, None, components, None, conduits ) else: return v0_2.Model(str(model.name), None, description, None, [], []) diff --git a/ymmsl/io.py b/ymmsl/io.py index 75e87fa..84822c1 100644 --- a/ymmsl/io.py +++ b/ymmsl/io.py @@ -55,6 +55,7 @@ v0_2.ImportKind, v0_2.ImportStatement, v0_2.KeepsStateForNextUse, + v0_2.MatchingTimelines, v0_2.Model, v0_2.MPICoresResReq, v0_2.MPINodesResReq, @@ -67,6 +68,7 @@ v0_2.Settings, v0_2.SupportedSetting, v0_2.SupportedSettings, + v0_2.Timeline, v0_2.ThreadedResReq, ) diff --git a/ymmsl/v0_2/__init__.py b/ymmsl/v0_2/__init__.py index 6fb04b8..5a9a267 100644 --- a/ymmsl/v0_2/__init__.py +++ b/ymmsl/v0_2/__init__.py @@ -11,7 +11,7 @@ from ymmsl.v0_2.identity import Identifier, Reference, ReferencePart from ymmsl.v0_2.implementation import Implementation from ymmsl.v0_2.imports import ImportKind, ImportStatement -from ymmsl.v0_2.model import Conduit, ConduitFilter, Model +from ymmsl.v0_2.model import Conduit, ConduitFilter, MatchingTimelines, Model from ymmsl.v0_2.ports import Operator, Port, Ports, Timeline from ymmsl.v0_2.program import Program from ymmsl.v0_2.resolver import resolve @@ -59,6 +59,7 @@ "ImportStatement", "InconsistentTimelines", "KeepsStateForNextUse", + "MatchingTimelines", "Model", "MPICoresResReq", "MPINodesResReq", diff --git a/ymmsl/v0_2/model.py b/ymmsl/v0_2/model.py index 86e8394..432d87b 100644 --- a/ymmsl/v0_2/model.py +++ b/ymmsl/v0_2/model.py @@ -8,7 +8,7 @@ from ymmsl.v0_2.component import Component from ymmsl.v0_2.identity import Identifier, Reference from ymmsl.v0_2.implementation import Implementation -from ymmsl.v0_2.ports import Ports +from ymmsl.v0_2.ports import Ports, Timeline from ymmsl.v0_2.supported_settings import SupportedSettings @@ -267,6 +267,83 @@ def as_conduits(self) -> List[Conduit]: AnyConduit: TypeAlias = Conduit | MulticastConduit +class MatchingTimelines: + """Represents a set of matching timelines. + + Matching timelines are timelines generated by different components that have the + same time points. This can happen if multiple components are configured to generate + the same time points, or if a component adapts its timeline to that of another + component, like time bridges do. + + Semantically, this is an equivalence class and so all timelines are equivalent, but + we record a "head" of the set so that we can express which timeline is leading and + which ones are following it in asymmetric situations like with time bridges. + + In YAML, these can be expressed as a dictionary, similarly to conduits: + + timeline1: timeline2 + timeline3: + - timeline4 + - timeline5 + timeline6: timeline7 timeline8 + macro1:micro1: macro2:micro2 + + Attributes: + head: "Main" timeline of this set of matching timelines + matches: Set of all matching timelines, including the head + """ + + def __init__( + self, + head: str | Timeline, + matches: str | Timeline | List[str] | List[Timeline], + ) -> None: + """Create a MatchingTimelines. + + The matches argument may be a string containing whitespace-separated timelines, + a Timeline object, a list of Timelines, or a list of strings representing a + single timeline each. + + The "head" timeline doesn't have to be passed in both arguments, it will be + added to matches if needed. + + Args: + head: "Main" timeline of this set of matching timelines + matches: Matching timelines + """ + self.head = head if isinstance(head, Timeline) else Timeline(head) + + if isinstance(matches, str): + self.matches = {Timeline(t) for t in matches.split()} + elif isinstance(matches, Timeline): + self.matches = {matches} + else: + if not matches: + raise RuntimeError( + "To declare matching timelines, you need to specify at least two of" + " them" + ) + self.matches = { + Timeline(tl) if isinstance(tl, str) else tl for tl in matches + } + self.matches.add(self.head) + + def __contains__(self, timeline: Timeline) -> bool: + return timeline in self.matches + + def _yatiml_attributes(self) -> OrderedDict: + matches: str | list[str] = list(map(str, sorted(self.matches - {self.head}))) + if len(matches) < 6 and sum(len(m) for m in matches) < 60: + matches = " ".join(matches) + + return OrderedDict( + [ + ("head", str(self.head)), + ("matches", matches), + ] + ) + + class Model(Implementation): """Describes a simulation model. @@ -284,6 +361,7 @@ class Model(Implementation): supported_settings: Settings supported by this model. components: A list of components making up the model. conduits: A list of conduits connecting the components. + matching_timelines: A list of sets of matching timelines. """ def __init__( @@ -293,6 +371,7 @@ def __init__( description: str = "", supported_settings: SupportedSettings | None = None, components: Sequence[Component] | None = None, + matching_timelines: Sequence[MatchingTimelines] | None = None, conduits: Sequence[AnyConduit] | None = None, ) -> None: """Create a Model. @@ -303,6 +382,7 @@ def __init__( description: Human-readable description of the model supported_settings: Settings supported by this model components: A list of components making up the model + matching_timelines: A list of matching timelines conduits: A list of conduits connecting the components """ super().__init__(name, ports, description, supported_settings) @@ -322,6 +402,8 @@ def __init__( self.components = {copy(c.name): c for c in components} + self.matching_timelines = matching_timelines + self.conduits: list[Conduit] = list() if conduits: for conduit in conduits: @@ -448,6 +530,7 @@ def _yatiml_attributes(self) -> OrderedDict: ("description", self.description), ("supported_settings", self.supported_settings), ("components", self.components), + ("matching_timelines", self.matching_timelines), ("conduits", self._conduits_for_export()), ] ) @@ -461,6 +544,7 @@ def _yatiml_recognize(cls, node: yatiml.UnknownNode) -> None: def _yatiml_savorize(cls, node: yatiml.Node) -> None: node.map_attribute_to_seq("components", "name") node.map_attribute_to_seq("conduits", "sender", "receiver") + node.map_attribute_to_seq("matching_timelines", "head", "matches") @classmethod def _yatiml_sweeten(cls, node: yatiml.Node) -> None: @@ -470,3 +554,9 @@ def _yatiml_sweeten(cls, node: yatiml.Node) -> None: node.remove_attribute("conduits") node.seq_attribute_to_map("conduits", "sender", "receiver") + + mt_node = node.get_attribute("matching_timelines") + if mt_node.is_scalar(None) or len(mt_node.seq_items()) == 0: + node.remove_attribute("matching_timelines") + + node.seq_attribute_to_map("matching_timelines", "head", "matches") diff --git a/ymmsl/v0_2/ports.py b/ymmsl/v0_2/ports.py index 0b21530..04dc439 100644 --- a/ymmsl/v0_2/ports.py +++ b/ymmsl/v0_2/ports.py @@ -8,30 +8,31 @@ class Timeline: """Identify a timeline on which a port sends or receives. - Timeline objects describe when a port sends or receives, either relative to a parent - timeline that calls the component they're a part of, or by describing the whole list - of components calling each other from the root timeline down. - - A component c1 that is not called by any other component will have its F_INIT and - O_F ports (if any) on the root timeline, which is represented by ':'. If c1's - implementation has a loop in which it sends on an O_I port and receives on an S - port, then those ports are on a subtimeline, which is named after the component by - default, ':c1'. - - If we add a component c2 and connect its F_INIT and O_F to those ports, then we - create a macro-micro type coupling. The F_INIT and O_F ports of c2 will then be on - timeline ':c1', because they'll receive and send at the exact points in simulated - time that c1's O_I and S ports send and receive. - - If c2 has its own O_I and S ports, then those will be on timeline ':c1:c2', this - being the concatenation of the parent timeline and the relative timeline within c2. - - Some implementations may have more than one set of O_I/S ports, on which they - communicate at different rates. In that case, each port should be given a local - relative timeline explicitly. In the above example, if c1's O_I and S ports were - specified to be on local relative timeline 'tl1', then their full relative timeline - is 'c1.tl' and their absolute timeline is ':c1.tl`, putting c2's O_I and S ports on - ':c1.tl:c2' unless they too have an explicit timeline designation. + A timeline is a stretch of time with a beginning and an end that is subdivided into + zero or more time steps that step from one intermediate time point to the next. + + F_INIT ports receive at the beginning of the timeline, O_F ports send at the end of + the timeline, O_I ports send at the first and intermediate points on the timeline, + and S ports receive at intermediate and the last time point of the timeline. + + Timeline objects are used to annotate components and ports, to describe which + timeline they're on. + + For components, the timeline depends on where in a call hierarchy the component + sits. A component ``c1`` that is not called (via an O_I to F_INIT conduit) by any + other model has timeline ``c1``, relative to the model it's in. If ``c2`` is called + by ``c1``, then it is in timeline ``c2`` relative to its *parent timeline* ``c1``, + and in ``c1:c2`` relative to the model. If ``c1`` dispatches (O_F to F_INIT) to + ``c3``, then ``c3``'s parent timeline is the same as ``c1``'s parent timeline, which + is the empty model timeline, putting component ``c3`` into timeline ``c3``. + + Ports are on the timeline of the component they're associated with. Some components + however have multiple sets of O_I/S ports, on which they communicate at different + rates. Each group of such ports is annotated, using this class, with a timeline + annotation naming the sub-timeline that port is on. If an O_I port on component + ``c1`` is annotated with ``tl1``, then that port will be on timeline ``c1.tl1`` + rather than on ``c1``, and if it were on ``c2`` then the full timeline would be + ``c1:c2.tl1``. Timelines have a technical representation as a list of References, and a string representation in which those References are joined using colons. @@ -41,14 +42,11 @@ class Timeline: def __init__(self, timeline: str) -> None: ... @overload - def __init__( - self, timeline: Sequence[str | Reference], absolute: bool = True - ) -> None: ... + def __init__(self, timeline: Sequence[str | Reference]) -> None: ... def __init__( self, timeline: str | Sequence[str | Reference], - absolute: bool = True, ) -> None: """Create a Timeline. @@ -60,42 +58,39 @@ def make_new_reference(x: str | Reference) -> Reference: return Reference(str(x)) if isinstance(timeline, str): + timeline = timeline.strip(":") if timeline == "": - self.absolute = False - parts: Sequence[str | Reference] = [] - elif timeline == ":": - self.absolute = True - parts = [] - else: - self.absolute = False - if timeline[0] == ":": - self.absolute = True - timeline = timeline[1:] + self._parts = [] + return - parts = timeline.split(":") + timeline = timeline.split(":") - self._parts = list(map(make_new_reference, parts)) - - else: - self.absolute = absolute - self._parts = list(map(make_new_reference, timeline)) + self._parts = list(map(make_new_reference, timeline)) def __eq__(self, other: Any) -> bool: """Compare with another Timeline or a string for equality.""" if isinstance(other, str): return str(self) == other elif isinstance(other, Timeline): - return self.absolute == other.absolute and self._parts == other._parts + return self._parts == other._parts return NotImplemented def __hash__(self) -> int: """Make this hashable so we can make sets of Timelines.""" return hash(str(self)) + def __lt__(self, other: Any) -> bool: + """Compare lexicographically by parts.""" + if isinstance(other, str): + other_tl = Timeline(other) + else: + other_tl = other + + return tuple(self._parts) < tuple(other_tl._parts) + def __str__(self) -> str: """Return the string representation of this Timeline.""" - anchor = ":" if self.absolute else "" - return anchor + ":".join(map(str, self._parts)) + return ":".join(map(str, self._parts)) def __repr__(self) -> str: """Return a representation of the object.""" @@ -129,16 +124,12 @@ def __iter__(self) -> Iterator[Reference]: yield from self._parts def __add__(self, other: Any) -> "Timeline": - """Concatenate this timeline with another (relative!) Timeline.""" + """Concatenate this timeline with another Timeline.""" if isinstance(other, Timeline): - if other.absolute: - raise ValueError( - "Cannot concatenate an absolute Timeline onto another one" - ) - return Timeline(self._parts + other._parts, self.absolute) + return Timeline(self._parts + other._parts) if isinstance(other, Reference): - return Timeline(self._parts + [other], self.absolute) + return Timeline(self._parts + [other]) return NotImplemented @@ -150,19 +141,16 @@ def parent(self) -> "Timeline": RuntimeError if this is the root and there is no parent.""" if not self._parts: raise RuntimeError("The root timeline does not have a parent") - return Timeline(self._parts[:-1], self.absolute) + return Timeline(self._parts[:-1]) def relative_to(self, other: "Timeline") -> "Timeline": """Compute a version of this timeline relative to `other`. - Both timelines must be absolute, and the other timeline must be a parent - timeline of this one. + The other timeline must be a parent timeline of this one. """ - if not self.absolute or not other.absolute: - raise ValueError("Both timelines must be absolute") if self._parts[: len(other)] != other._parts: raise ValueError(f"{self} is not a subtimeline of {other}") - return Timeline(self._parts[len(other) :], False) + return Timeline(self._parts[len(other) :]) class Port: diff --git a/ymmsl/v0_2/tests/conftest.py b/ymmsl/v0_2/tests/conftest.py index d728e86..69d29d0 100644 --- a/ymmsl/v0_2/tests/conftest.py +++ b/ymmsl/v0_2/tests/conftest.py @@ -1,4 +1,5 @@ from pathlib import Path +from textwrap import dedent import pytest @@ -12,6 +13,7 @@ Configuration, ExecutionModel, KeepsStateForNextUse, + MatchingTimelines, Model, MPICoresResReq, MPINodesResReq, @@ -52,6 +54,7 @@ def model() -> Model: Component("smc2bf", Ports("in", o_f="out"), "Grids domain", "smc2bf"), Component("bf2smc", Ports("in", o_f="out"), "Interpolates wss", "bf2smc"), ], + None, [ Conduit("ic.out", "smc.initial_state"), Conduit("smc.cell_positions", "smc2bf.in"), @@ -130,6 +133,7 @@ def model_multicast() -> Model: Component("b", Ports("in"), "Receives data", "b"), Component("c", Ports("in"), "Receives data", "b"), ], + None, [ Conduit("a.out", "b.in"), Conduit("a.out", "c.in"), @@ -208,6 +212,7 @@ def model_with_filters() -> Model: "micro2", ), ], + None, [ Conduit("init.macro_out", "macro1.init"), Conduit("init.micro_out", "micro1.init_state", "pad"), @@ -277,6 +282,60 @@ def model_with_filters_text() -> str: ) +@pytest.fixture +def model_matching_timelines() -> Model: + return Model( + "test_with_matching_timelines", + Ports(), + "Featuring lock-step interaction", + SupportedSettings(), + [ + Component( + "left", + Ports(o_i="out", s="in"), + "Left side of the domain", + ), + Component( + "right", + Ports(o_i="out", s="in"), + "Right side of the domain", + ), + ], + [MatchingTimelines("main", ["left", "right"])], + [ + Conduit("left.out", "right.in"), + Conduit("right.out", "left.in"), + ], + ) + + +@pytest.fixture +def model_matching_timelines_text() -> str: + return dedent("""\ + name: test_with_matching_timelines + description: | + Featuring lock-step interaction + components: + left: + ports: + o_i: out + s: in + description: | + Left side of the domain + right: + ports: + o_i: out + s: in + description: | + Right side of the domain + matching_timelines: + main: left right + conduits: + left.out: right.in + right.out: left.in + """) + + @pytest.fixture def test_program() -> Program: return Program( @@ -862,6 +921,7 @@ def config_component_loop() -> Configuration: ), Component("micro", Ports("init", o_f="final"), "Micro model", "micro"), ], + None, [ Conduit("init.final", "macro.init"), Conduit("macro.out", "micro.init"), @@ -885,6 +945,7 @@ def config_component_loop() -> Configuration: "second", ), ], + None, [ Conduit("init", "first.init"), Conduit("first.final", "second.init"), @@ -899,6 +960,7 @@ def config_component_loop() -> Configuration: "Processes the input a bit", None, [Component("micro", Ports("init", o_f="final"), "Ooops...", "submodel1")], + None, [Conduit("init", "micro.init"), Conduit("micro.final", "final")], ) diff --git a/ymmsl/v0_2/tests/test_configuration.py b/ymmsl/v0_2/tests/test_configuration.py index a34c8e4..ff5a852 100644 --- a/ymmsl/v0_2/tests/test_configuration.py +++ b/ymmsl/v0_2/tests/test_configuration.py @@ -224,6 +224,7 @@ def test_configuration_update_model_error() -> None: "micro", Ports(f_init="init", o_f="final"), "description" ), ], + None, [ Conduit("macro.out", "micro.init"), Conduit("micro.final", "macro.in"), @@ -260,6 +261,7 @@ def test_configuration_update_model_error() -> None: "description", None, [], + None, [Conduit("micro.final", "macro.in2")], ) ], diff --git a/ymmsl/v0_2/tests/test_model.py b/ymmsl/v0_2/tests/test_model.py index 8f25484..7f33fdc 100644 --- a/ymmsl/v0_2/tests/test_model.py +++ b/ymmsl/v0_2/tests/test_model.py @@ -1,10 +1,18 @@ +from typing import Callable + import pytest import yatiml from ymmsl.v0_2.component import Component from ymmsl.v0_2.identity import Identifier from ymmsl.v0_2.implementation import Implementation, Reference -from ymmsl.v0_2.model import Conduit, ConduitFilter, Model, MulticastConduit +from ymmsl.v0_2.model import ( + Conduit, + ConduitFilter, + MatchingTimelines, + Model, + MulticastConduit, +) from ymmsl.v0_2.ports import Operator, Port, Ports, Timeline from ymmsl.v0_2.supported_settings import ( SettingType, @@ -15,6 +23,45 @@ Ref = Reference +@pytest.fixture +def load_model() -> Callable: + return yatiml.load_function( + Model, + Component, + Conduit, + ConduitFilter, + Identifier, + MatchingTimelines, + MulticastConduit, + Ports, + Reference, + SettingType, + SupportedSetting, + SupportedSettings, + Timeline, + ) + + +@pytest.fixture +def dumps_model() -> Callable: + return yatiml.dumps_function( + Model, + Component, + Conduit, + ConduitFilter, + Identifier, + Implementation, + MatchingTimelines, + MulticastConduit, + Ports, + Reference, + SettingType, + SupportedSetting, + SupportedSettings, + Timeline, + ) + + def test_conduit_filter() -> None: assert ConduitFilter.LAST.is_reducer() assert ConduitFilter.REPEAT.is_repeater() @@ -104,7 +151,7 @@ def test_dump_conduit() -> None: def test_multicast_conduits() -> None: c1 = Conduit("macro.out", "micro.init") mc1 = MulticastConduit("micro.final", ["macro.in", "micro2.init"]) - m = Model("test_model", None, "description", None, [], [c1, mc1]) + m = Model("test_model", None, "description", None, [], None, [c1, mc1]) assert m.conduits[0] is c1 assert m.conduits[1].sender == "micro.final" @@ -147,21 +194,83 @@ def test_dump_multicast_conduits() -> None: assert text == ("sender: init.out\nreceiver:\n- c1.in\n- repeat pad c2.in\n") -def test_load_model(model_text: str) -> None: - load_model = yatiml.load_function( - Model, - Component, - Conduit, - ConduitFilter, - Identifier, - MulticastConduit, - Ports, - Reference, - SettingType, - SupportedSetting, - SupportedSettings, - ) - +def test_create_matching_timeline() -> None: + mt = MatchingTimelines(Timeline("tl1"), "tl2") + assert isinstance(mt.head, Timeline) + assert mt.head == Timeline("tl1") + assert isinstance(mt.matches, set) + assert mt.matches == {Timeline("tl1"), Timeline("tl2")} + + mt = MatchingTimelines(Timeline("tl1"), ["tl2", "tl3"]) + assert isinstance(mt.head, Timeline) + assert mt.head == Timeline("tl1") + assert isinstance(mt.matches, set) + assert mt.matches == {Timeline("tl1"), Timeline("tl2"), Timeline("tl3")} + + mt = MatchingTimelines(Timeline("tl1"), [Timeline("tl2")]) + assert isinstance(mt.head, Timeline) + assert mt.head == Timeline("tl1") + assert isinstance(mt.matches, set) + assert mt.matches == {Timeline("tl1"), Timeline("tl2")} + + mt = MatchingTimelines(Timeline("tl1"), "tl2 tl4 tl5") + assert isinstance(mt.head, Timeline) + assert mt.head == Timeline("tl1") + assert isinstance(mt.matches, set) + assert mt.matches == { + Timeline("tl1"), + Timeline("tl2"), + Timeline("tl4"), + Timeline("tl5"), + } + + +def test_load_matching_timelines() -> None: + load = yatiml.load_function(MatchingTimelines, Timeline) + + text = "head: timeline1\nmatches: timeline2" + mt = load(text) + assert isinstance(mt.head, Timeline) + assert mt.head == "timeline1" + + assert isinstance(mt.matches, set) + assert all(isinstance(m, Timeline) for m in mt.matches) + assert mt.matches == {Timeline("timeline1"), Timeline("timeline2")} + + for text in ( + "head: common\nmatches:\n- timeline1\n- timeline2", + "head: common\nmatches: timeline1 timeline2", + ): + mt = load(text) + assert isinstance(mt.head, Timeline) + assert mt.head == "common" + + assert isinstance(mt.matches, set) + assert all(isinstance(m, Timeline) for m in mt.matches) + assert mt.matches == { + Timeline("common"), + Timeline("timeline1"), + Timeline("timeline2"), + } + + +def test_dump_matching_timelines() -> None: + dumps = yatiml.dumps_function(MatchingTimelines, Timeline) + + mt = MatchingTimelines("timeline1", "timeline2") + text = dumps(mt) + assert text == "head: timeline1\nmatches: timeline2\n" + + mt = MatchingTimelines("common", ["timeline1", "timeline2"]) + text = dumps(mt) + assert text == "head: common\nmatches: timeline1 timeline2\n" + + mt = MatchingTimelines("a", "b c d e f g") + text = dumps(mt) + assert text == "head: a\nmatches:\n- b\n- c\n- d\n- e\n- f\n- g\n" + + +def test_load_model(load_model: Callable, model_text: str) -> None: m = load_model(model_text) assert m.name == "test_model" @@ -183,21 +292,9 @@ def test_load_model(model_text: str) -> None: assert m.conduits[4].sender == Reference("bf2smc.out") -def test_load_model_with_multicast_conduits(model_multicast_text: str) -> None: - - load_model = yatiml.load_function( - Model, - Component, - Conduit, - ConduitFilter, - Identifier, - MulticastConduit, - Ports, - Reference, - SettingType, - SupportedSettings, - ) - +def test_load_model_with_multicast_conduits( + load_model: Callable, model_multicast_text: str +) -> None: m = load_model(model_multicast_text) assert m.conduits[0].sender == "a.out" @@ -206,20 +303,9 @@ def test_load_model_with_multicast_conduits(model_multicast_text: str) -> None: assert m.conduits[1].receiver == "c.in" -def test_load_model_with_filters(model_with_filters_text: str) -> None: - load_model = yatiml.load_function( - Model, - Component, - Conduit, - ConduitFilter, - Identifier, - MulticastConduit, - Ports, - Reference, - SettingType, - SupportedSettings, - ) - +def test_load_model_with_filters( + load_model: Callable, model_with_filters_text: str +) -> None: m = load_model(model_with_filters_text) assert m.name == "test_model_conduit_filters" @@ -244,20 +330,7 @@ def test_load_model_with_filters(model_with_filters_text: str) -> None: assert m.conduits[5].filters == [ConduitFilter.LAST, ConduitFilter.PAD] -def test_load_model_with_invalid_filters() -> None: - load_model = yatiml.load_function( - Model, - Component, - Conduit, - ConduitFilter, - Identifier, - MulticastConduit, - Ports, - Reference, - SettingType, - SupportedSettings, - ) - +def test_load_model_with_invalid_filters(load_model: Callable) -> None: text = ( "name: test_model_with_invalid_filters\n" "description: Testing invalid filters\n" @@ -269,67 +342,49 @@ def test_load_model_with_invalid_filters() -> None: load_model(text) -def test_dump_model(model: Model, model_text: str) -> None: - dumps_model = yatiml.dumps_function( - Model, - Component, - Conduit, - Identifier, - Implementation, - MulticastConduit, - Ports, - Reference, - SettingType, - SupportedSetting, - SupportedSettings, - ) +def test_load_model_with_timelines( + load_model: Callable, model_matching_timelines_text: str +) -> None: + model = load_model(model_matching_timelines_text) + assert model.matching_timelines is not None + assert len(model.matching_timelines) == 1 + assert model.matching_timelines[0].head == Timeline("main") + assert model.matching_timelines[0].matches == { + Timeline("left"), + Timeline("main"), + Timeline("right"), + } + + +def test_dump_model(dumps_model: Callable, model: Model, model_text: str) -> None: text = dumps_model(model) assert text == model_text def test_dump_model_with_multicast_conduits( - model_multicast: Model, model_multicast_text: str + dumps_model: Callable, model_multicast: Model, model_multicast_text: str ) -> None: - - dumps_model = yatiml.dumps_function( - Model, - Component, - Conduit, - Identifier, - Implementation, - MulticastConduit, - Ports, - Reference, - SettingType, - SupportedSettings, - ) - text = dumps_model(model_multicast) assert text == model_multicast_text def test_dump_model_with_filters( - model_with_filters: Model, model_with_filters_text: str + dumps_model: Callable, model_with_filters: Model, model_with_filters_text: str ) -> None: - dumps_model = yatiml.dumps_function( - Model, - Component, - Conduit, - ConduitFilter, - Identifier, - Implementation, - MulticastConduit, - Ports, - Reference, - SettingType, - SupportedSettings, - ) - text = dumps_model(model_with_filters) assert text == model_with_filters_text +def test_dump_model_with_matching_timelines( + dumps_model: Callable, + model_matching_timelines: Model, + model_matching_timelines_text: str, +) -> None: + text = dumps_model(model_matching_timelines) + assert text == model_matching_timelines_text + + def test_consistent() -> None: model_ports = Ports(f_init=["model_init"], o_f=["model_final"]) @@ -347,7 +402,13 @@ def test_consistent() -> None: ] model = Model( - "with_conduits", model_ports, "description", None, [macro, micro], conduits + "with_conduits", + model_ports, + "description", + None, + [macro, micro], + None, + conduits, ) errors = model.check_consistent() @@ -373,7 +434,7 @@ def test_conduits_inconsistent() -> None: ] model = Model( - "bad_conduits", model_ports, "description", None, [macro, micro], conduits + "bad_conduits", model_ports, "description", None, [macro, micro], None, conduits ) errors = model.check_consistent() diff --git a/ymmsl/v0_2/tests/test_ports.py b/ymmsl/v0_2/tests/test_ports.py index 0947c15..8ff544d 100644 --- a/ymmsl/v0_2/tests/test_ports.py +++ b/ymmsl/v0_2/tests/test_ports.py @@ -9,54 +9,41 @@ def test_create_empty_timeline() -> None: tl = Timeline("") - assert tl.absolute is False assert len(tl._parts) == 0 def test_create_root_timeline() -> None: tl = Timeline(":") - assert tl.absolute is True assert len(tl._parts) == 0 -def test_create_absolute_timeline() -> None: - tl = Timeline(":timeline") - assert tl.absolute is True - assert len(tl._parts) == 1 - assert tl._parts[0] == "timeline" - - -def test_create_relative_timeline() -> None: +def test_create_timeline() -> None: tl = Timeline("timeline") - assert tl.absolute is False assert len(tl._parts) == 1 assert tl._parts[0] == "timeline" def test_create_full_timeline() -> None: tl = Timeline("c1.a:c2.b:c3:c4") - assert tl.absolute is False assert len(tl._parts) == 4 assert tl._parts == ["c1.a", "c2.b", "c3", "c4"] -def test_create_absolute_from_list_of_str() -> None: - tl = Timeline(["c1.a", "c2.b", "c3", "c4"], True) - assert tl.absolute is True +def test_create_from_list_of_str() -> None: + tl = Timeline(["c1.a", "c2.b", "c3", "c4"]) assert len(tl._parts) == 4 assert tl._parts == ["c1.a", "c2.b", "c3", "c4"] -def test_create_relative_from_list_of_ref() -> None: - tl = Timeline([Ref("c1"), Ref("c2.a")], False) - assert tl.absolute is False +def test_create_from_list_of_ref() -> None: + tl = Timeline([Ref("c1"), Ref("c2.a")]) assert len(tl._parts) == 2 assert tl._parts == ["c1", "c2.a"] def test_timeline_equality() -> None: tl1 = Timeline(":c0:c1:c2.a") - tl2 = Timeline(["c0", "c1", "c2.a"], True) + tl2 = Timeline(["c0", "c1", "c2.a"]) assert tl1 == tl2 tl2._parts[1] = Reference("c1.x") @@ -65,26 +52,14 @@ def test_timeline_equality() -> None: tl1._parts[1] = Reference("c1.x") assert tl1 == tl2 - tl2.absolute = False - assert tl1 != tl2 - - tl1.absolute = False - assert tl1 == tl2 - - tl2.absolute = True - assert tl1 != tl2 - def test_timeline_to_str() -> None: - tl = Timeline([Ref("c1"), Ref("c2.a"), Ref("c3")], False) + tl = Timeline([Ref("c1"), Ref("c2.a"), Ref("c3")]) assert str(tl) == "c1:c2.a:c3" - tl.absolute = True - assert str(tl) == ":c1:c2.a:c3" - def test_timeline_indexing() -> None: - tl = Timeline(":c0:c1:c2.p:c3.q") + tl = Timeline("c0:c1:c2.p:c3.q") assert len(tl) == 4 assert isinstance(tl[0], Reference) assert tl[0] == "c0" @@ -98,20 +73,17 @@ def test_timeline_indexing() -> None: with pytest.raises(IndexError): tl[4] + assert tl[0:2] == "c0:c1" + assert tl[1:3] == "c1:c2.p" + def test_timeline_concatenation() -> None: - tl1 = Timeline(":c1:c2.a") + tl1 = Timeline("c1:c2.a") tl2 = Timeline("c3.b:c4") tl3 = tl1 + tl2 - assert tl3.absolute is True assert len(tl3._parts) == 4 - assert tl3 == Timeline(":c1:c2.a:c3.b:c4") - - tl2.absolute = True - - with pytest.raises(ValueError): - tl1 + tl2 + assert tl3 == Timeline("c1:c2.a:c3.b:c4") def test_timeline_concatenate_empty() -> None: @@ -133,20 +105,14 @@ def test_timeline_parent() -> None: _ = Timeline(":").parent assert Timeline("a:b").parent == Timeline("a") - assert Timeline(":a:b").parent == Timeline(":a") def test_timeline_relative_to() -> None: - assert Timeline(":a:b:c").relative_to(Timeline(":a:b")) == Timeline("c") - assert Timeline(":a:b:c").relative_to(Timeline(":a")) == Timeline("b:c") - - with pytest.raises(ValueError, match="absolute"): - Timeline("a:b").relative_to(Timeline(":a")) - with pytest.raises(ValueError, match="absolute"): - Timeline(":a:b").relative_to(Timeline("a")) + assert Timeline("a:b:c").relative_to(Timeline("a:b")) == Timeline("c") + assert Timeline("a:b:c").relative_to(Timeline("a")) == Timeline("b:c") with pytest.raises(ValueError, match="subtimeline"): - Timeline(":a:b").relative_to(Timeline(":b")) + Timeline("a:b").relative_to(Timeline("b")) def test_create_empty_ports() -> None: diff --git a/ymmsl/v0_2/tests/test_timeline_resolver.py b/ymmsl/v0_2/tests/test_timeline_resolver.py index 407efce..805049a 100644 --- a/ymmsl/v0_2/tests/test_timeline_resolver.py +++ b/ymmsl/v0_2/tests/test_timeline_resolver.py @@ -3,7 +3,7 @@ import pytest import ymmsl -from ymmsl.v0_2 import ConduitFilter, Configuration, Timeline +from ymmsl.v0_2 import ConduitFilter, Configuration, MatchingTimelines, Timeline from ymmsl.v0_2 import Reference as Ref from ymmsl.v0_2.timeline_resolver import ( ConduitTimelineError, @@ -29,15 +29,15 @@ def test_consistent_configuration(timelines_configuration: Configuration) -> Non def test_dispatch(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("dispatch")] resolve_timelines(model) - assert model.components[Ref("first")].timeline == ":first" - assert model.components[Ref("second")].timeline == ":second" + assert model.components[Ref("first")].timeline == "first" + assert model.components[Ref("second")].timeline == "second" def test_macromicro(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("macromicro")] resolve_timelines(model) - assert model.components[Ref("macro")].timeline == Timeline(":macro") - assert model.components[Ref("micro")].timeline == Timeline(":macro:micro") + assert model.components[Ref("macro")].timeline == Timeline("macro") + assert model.components[Ref("micro")].timeline == Timeline("macro:micro") def test_cycle(timelines_configuration: Configuration) -> None: @@ -48,8 +48,8 @@ def test_cycle(timelines_configuration: Configuration) -> None: def test_reducer(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("reducer")] resolve_timelines(model) - assert model.components[Ref("first")].timeline == Timeline(":first") - assert model.components[Ref("second")].timeline == Timeline(":second") + assert model.components[Ref("first")].timeline == Timeline("first") + assert model.components[Ref("second")].timeline == Timeline("second") def test_only_reducer(timelines_configuration: Configuration) -> None: @@ -58,8 +58,8 @@ def test_only_reducer(timelines_configuration: Configuration) -> None: del model.conduits[0] assert model.conduits[0].filters == [ConduitFilter("last")] resolve_timelines(model) - assert model.components[Ref("first")].timeline == Timeline(":first") - assert model.components[Ref("second")].timeline == Timeline(":second") + assert model.components[Ref("first")].timeline == Timeline("first") + assert model.components[Ref("second")].timeline == Timeline("second") def test_too_many_reducers(timelines_configuration: Configuration) -> None: @@ -77,9 +77,9 @@ def test_inconsistent_timelines(timelines_configuration: Configuration) -> None: def test_repeaters(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("repeaters")] resolve_timelines(model) - assert model.components[Ref("macro")].timeline == Timeline(":macro") - assert model.components[Ref("meso")].timeline == Timeline(":macro:meso") - assert model.components[Ref("micro")].timeline == Timeline(":macro:meso:micro") + assert model.components[Ref("macro")].timeline == Timeline("macro") + assert model.components[Ref("meso")].timeline == Timeline("macro:meso") + assert model.components[Ref("micro")].timeline == Timeline("macro:meso:micro") def test_too_many_repeaters(timelines_configuration: Configuration) -> None: @@ -106,10 +106,10 @@ def test_repeater_and_too_many_reducers(timelines_configuration: Configuration) def test_repeater_after_reducer(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("repeater_reducer")] resolve_timelines(model) - assert model.components[Ref("macro1")].timeline == Timeline(":macro1") - assert model.components[Ref("macro2")].timeline == Timeline(":macro2") - assert model.components[Ref("micro1")].timeline == Timeline(":macro1:micro1") - assert model.components[Ref("micro2")].timeline == Timeline(":macro2:micro2") + assert model.components[Ref("macro1")].timeline == Timeline("macro1") + assert model.components[Ref("macro2")].timeline == Timeline("macro2") + assert model.components[Ref("micro1")].timeline == Timeline("macro1:micro1") + assert model.components[Ref("micro2")].timeline == Timeline("macro2:micro2") # Remove filters on the last conduit to make the incoming timelines inconsistent model.conduits[-1].filters = [] @@ -123,16 +123,18 @@ def test_repeater_after_reducer_error(timelines_configuration: Configuration) -> resolve_timelines(model) model.conduits[-1].filters = [] resolve_timelines(model) - assert model.components[Ref("macro")].timeline == Timeline(":macro") - assert model.components[Ref("micro1")].timeline == Timeline(":macro:micro1") - assert model.components[Ref("micro2")].timeline == Timeline(":macro:micro2") + assert model.components[Ref("macro")].timeline == Timeline("macro") + assert model.components[Ref("micro1")].timeline == Timeline("macro:micro1") + assert model.components[Ref("micro2")].timeline == Timeline("macro:micro2") def test_inconsistent_interact(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("inconsistent_interact")] with pytest.raises(ConduitTimelineError, match="missing timeline annotations"): resolve_timelines(model) - # TODO: add matching_timelines and try again successfully + + model.matching_timelines = [MatchingTimelines("A", "B")] + resolve_timelines(model) def test_subtimelines(timelines_configuration: Configuration) -> None: @@ -153,3 +155,11 @@ def test_model_ports(timelines_configuration: Configuration) -> None: def test_muscle_settings_in(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("qmc")] resolve_timelines(model) + + +def test_interact_time_bridge_matching(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("interact_time_bridge")] + resolve_timelines(model) + + assert model.components[Ref("A")].timeline == Timeline("A") + assert model.components[Ref("bridge")].timeline == Timeline("bridge") diff --git a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl index dacf9e5..a8728b5 100644 --- a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl +++ b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl @@ -265,3 +265,22 @@ models: micro1.final: macro.in1 macro.out2: micro2.init micro2.final: macro.in2 + + interact_time_bridge: + components: + A: + description: A + ports: + o_i: clock_out + s: data_in + bridge: + description: Time bridge + ports: + timeline receiver: + o_i: data_out + s: clock_in + matching_timelines: + A: bridge.receiver + conduits: + A.clock_out: bridge.clock_in + bridge.data_out: A.data_in diff --git a/ymmsl/v0_2/timeline_resolver.py b/ymmsl/v0_2/timeline_resolver.py index d8c395a..2b55902 100644 --- a/ymmsl/v0_2/timeline_resolver.py +++ b/ymmsl/v0_2/timeline_resolver.py @@ -306,10 +306,10 @@ def timeline_for_port( port = self._all_ports[port_name] if port.timeline: subtimeline = Timeline( - [f"{component}.{name}" for name in port.timeline], False + [f"{component}.{name}" for name in port.timeline] ) else: - subtimeline = Timeline([component], False) + subtimeline = Timeline([component]) result = parent_tl + subtimeline @@ -361,19 +361,17 @@ def check_consistent(self) -> None: continue # Check consistency + filtered_tl1 = timeline1[:-num_reducers] if num_reducers else timeline1 + filtered_tl2 = timeline2[:-num_repeaters] if num_repeaters else timeline2 + if self._model.matching_timelines: + for mt in self._model.matching_timelines: + if filtered_tl1 in mt and filtered_tl2 in mt: + return + common_idx = len(timeline1) - num_reducers - for idx, (part1, part2) in enumerate( - zip(timeline1, timeline2, strict=False) - ): - if idx < common_idx: - if part1 != part2: - raise ConduitTimelineError(self, conduit, timeline1, timeline2) - else: - if part1 == part2: - hint = " You may need to remove a repeater and reducer filter." - raise ConduitTimelineError( - self, conduit, timeline1, timeline2, hint - ) + self._check_consistent_equal_length( + conduit, timeline1, timeline2, common_idx + ) def format_timelines(self) -> str: """Create a formatted list of determined timelines per component.""" @@ -382,3 +380,22 @@ def format_timelines(self) -> str: for comp, tl in self._parent_timeline.items() if len(comp) > 0 # Ony print actual components ) + + def _check_consistent_equal_length( + self, + conduit: Conduit, + timeline1: Timeline, + timeline2: Timeline, + common_idx: int, + ) -> None: + """Check that two equal-length (modulo filters) timelines are consistent.""" + for idx, (part1, part2) in enumerate(zip(timeline1, timeline2, strict=False)): + if idx < common_idx: + if part1 != part2: + raise ConduitTimelineError(self, conduit, timeline1, timeline2) + else: + if part1 == part2: + hint = " You may need to remove a repeater and reducer filter." + raise ConduitTimelineError( + self, conduit, timeline1, timeline2, hint + )