diff --git a/ymmsl/v0_2/__init__.py b/ymmsl/v0_2/__init__.py index 0f25f54..3646590 100644 --- a/ymmsl/v0_2/__init__.py +++ b/ymmsl/v0_2/__init__.py @@ -27,6 +27,14 @@ SupportedSetting, SupportedSettings, ) +from ymmsl.v0_2.timeline_resolver import ( + ConduitTimelineError, + CyclicDependency, + InconsistentTimelines, + ResolveTimelineException, + TooManyReducerFilters, + resolve_timelines, +) __all__ = [ "BaseEnv", @@ -38,13 +46,16 @@ "Ports", "Conduit", "ConduitFilter", + "ConduitTimelineError", "Configuration", + "CyclicDependency", "Document", "ExecutionModel", "Identifier", "Implementation", "ImportKind", "ImportStatement", + "InconsistentTimelines", "KeepsStateForNextUse", "Model", "MPICoresResReq", @@ -56,6 +67,8 @@ "Reference", "ReferencePart", "resolve", + "resolve_timelines", + "ResolveTimelineException", "ResourceRequirements", "Settings", "SettingType", @@ -64,4 +77,5 @@ "SupportedSettings", "ThreadedResReq", "Timeline", + "TooManyReducerFilters", ] diff --git a/ymmsl/v0_2/component.py b/ymmsl/v0_2/component.py index e04b340..c731fd8 100644 --- a/ymmsl/v0_2/component.py +++ b/ymmsl/v0_2/component.py @@ -5,7 +5,7 @@ from ymmsl.util import remove_trailing_whitespace from ymmsl.v0_2.identity import Reference -from ymmsl.v0_2.ports import Ports +from ymmsl.v0_2.ports import Ports, Timeline class Component: @@ -30,6 +30,9 @@ class Component: implementation: A Model or Program implementing this component optional: Whether this component is optional multiplicity: The shape of the set of instances + timeline: The resolved (absolute) timeline for this component inside the model. + This will be ``None`` until resolved by + :meth:`~ymmsl.v0_2.resolve_timelines()`. """ def __init__( @@ -56,6 +59,7 @@ def __init__( self.ports = ports self.description = description self.optional = optional + self.timeline: Optional[Timeline] = None if implementation is not None: self.implementation: Optional[Reference] = Reference(implementation) diff --git a/ymmsl/v0_2/ports.py b/ymmsl/v0_2/ports.py index 62af6d8..d479ace 100644 --- a/ymmsl/v0_2/ports.py +++ b/ymmsl/v0_2/ports.py @@ -76,7 +76,7 @@ def make_new_reference(x: Union[str, Reference]) -> Reference: self._parts = list(map(make_new_reference, parts)) - elif isinstance(timeline, list): + else: self.absolute = absolute self._parts = list(map(make_new_reference, timeline)) @@ -109,6 +109,10 @@ def __getitem__(self, index: int) -> Reference: """Return the index'th item in the timeline.""" return self._parts[index] + def __iter__(self) -> Iterator[Reference]: + """Iterate over the timeline parts.""" + yield from self._parts + def __add__(self, other: Any) -> "Timeline": """Concatenate this timeline with another (relative!) Timeline.""" if isinstance(other, Timeline): @@ -119,6 +123,25 @@ def __add__(self, other: Any) -> "Timeline": return Timeline(self._parts + other._parts, self.absolute) return NotImplemented + @property + def parent(self) -> "Timeline | None": + """Get the parent of this timeline. Returns None when there is no parent.""" + if not self._parts: + return None + return Timeline(self._parts[:-1], self.absolute) + + 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. + """ + 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) + class Port: """A port on a component. @@ -285,6 +308,15 @@ def __iter__(self) -> Iterator[Identifier]: """Iterate through the ports' names.""" yield from self._ports + def items(self) -> Iterator[tuple[Identifier, Port]]: + yield from self._ports.items() + + def keys(self) -> Iterator[Identifier]: + yield from self._ports.keys() + + def values(self) -> Iterator[Port]: + yield from self._ports.values() + def sending_port_names(self) -> List[Identifier]: """Return the names of all the sending ports. diff --git a/ymmsl/v0_2/tests/test_ports.py b/ymmsl/v0_2/tests/test_ports.py index 96642c7..dbeb348 100644 --- a/ymmsl/v0_2/tests/test_ports.py +++ b/ymmsl/v0_2/tests/test_ports.py @@ -125,6 +125,27 @@ def test_timeline_concatenate_empty() -> None: assert tl4 == tl1 +def test_timeline_parent() -> None: + assert Timeline("").parent is None + assert Timeline(":").parent is None + + 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")) + + with pytest.raises(ValueError, match="subtimeline"): + Timeline(":a:b").relative_to(Timeline(":b")) + + def test_create_empty_ports() -> None: p = Ports() assert len(p._ports) == 0 diff --git a/ymmsl/v0_2/tests/test_timeline_resolver.py b/ymmsl/v0_2/tests/test_timeline_resolver.py new file mode 100644 index 0000000..44a0b7c --- /dev/null +++ b/ymmsl/v0_2/tests/test_timeline_resolver.py @@ -0,0 +1,151 @@ +from pathlib import Path + +import pytest + +import ymmsl +from ymmsl.v0_2 import ConduitFilter, Configuration, Timeline +from ymmsl.v0_2 import Reference as Ref +from ymmsl.v0_2.timeline_resolver import ( + ROOT_TIMELINE, + ConduitTimelineError, + CyclicDependency, + InconsistentTimelines, + TooManyReducerFilters, + resolve_timelines, +) + + +@pytest.fixture() +def timelines_configuration() -> Configuration: + return ymmsl.load_as( + Configuration, Path(__file__).parent / "ymmsl1/timelines.ymmsl" + ) + + +def test_consistent_configuration(timelines_configuration: Configuration) -> None: + timelines_configuration.check_consistent() + + +def test_dispatch(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("dispatch")] + resolve_timelines(model) + assert model.components[Ref("first")].timeline == ROOT_TIMELINE + assert model.components[Ref("second")].timeline == ROOT_TIMELINE + + +def test_macromicro(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("macromicro")] + resolve_timelines(model) + assert model.components[Ref("macro")].timeline == ROOT_TIMELINE + assert model.components[Ref("micro")].timeline == Timeline(":macro") + + # Check that ports have the correct relative timelines: + macro = model.components[Ref("macro")] + assert macro.ports["init"].timeline == Timeline("") + assert macro.ports["out"].timeline == Timeline("macro") + assert macro.ports["in"].timeline == Timeline("macro") + + for micro_port in model.components[Ref("micro")].ports.values(): + assert micro_port.timeline == Timeline("") + + +def test_cycle(timelines_configuration: Configuration) -> None: + with pytest.raises(CyclicDependency, match="cycle in model"): + resolve_timelines(timelines_configuration.models[Ref("cycle")]) + + +def test_reducer(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("reducer")] + resolve_timelines(model) + assert model.components[Ref("first")].timeline == ROOT_TIMELINE + assert model.components[Ref("second")].timeline == ROOT_TIMELINE + + +def test_only_reducer(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("reducer")] + # Remove the conduit first.final -> second.init1 and keep only the reduced conduit + del model.conduits[0] + assert model.conduits[0].filters == [ConduitFilter("last")] + resolve_timelines(model) + assert model.components[Ref("first")].timeline == ROOT_TIMELINE + assert model.components[Ref("second")].timeline == ROOT_TIMELINE + + +def test_too_many_reducers(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("reducer")] + model.conduits[-1].filters.append(model.conduits[-1].filters[0]) + with pytest.raises(TooManyReducerFilters, match="too many reducer filters"): + resolve_timelines(model) + + +def test_inconsistent_timelines(timelines_configuration: Configuration) -> None: + with pytest.raises(InconsistentTimelines): + resolve_timelines(timelines_configuration.models[Ref("inconsistent")]) + + +def test_repeaters(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + resolve_timelines(model) + assert model.components[Ref("macro")].timeline == ROOT_TIMELINE + assert model.components[Ref("meso")].timeline == Timeline(":macro") + assert model.components[Ref("micro")].timeline == Timeline(":macro:meso") + + +def test_too_many_repeaters(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + model.conduits[-2].filters.append(ConduitFilter.REPEAT) + with pytest.raises(ConduitTimelineError, match="remove a repeater"): + resolve_timelines(model) + + +def test_too_few_repeaters(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + model.conduits[-1].filters.pop() + with pytest.raises(ConduitTimelineError, match="add a repeater"): + resolve_timelines(model) + + +def test_repeater_and_too_many_reducers(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + model.conduits[-1].filters.insert(0, ConduitFilter.LAST) + with pytest.raises(TooManyReducerFilters, match="too many reducer filters"): + resolve_timelines(model) + + +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 == ROOT_TIMELINE + assert model.components[Ref("macro2")].timeline == ROOT_TIMELINE + assert model.components[Ref("micro1")].timeline == Timeline(":macro1") + assert model.components[Ref("micro2")].timeline == Timeline(":macro2") + + # Remove filters on the last conduit to make the incoming timelines inconsistent + model.conduits[-1].filters = [] + with pytest.raises(InconsistentTimelines, match="different timelines"): + resolve_timelines(model) + + +def test_repeater_after_reducer_error(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeater_reducer_error")] + with pytest.raises(ConduitTimelineError, match="remove a repeater and reducer"): + resolve_timelines(model) + model.conduits[-1].filters = [] + resolve_timelines(model) + assert model.components[Ref("macro")].timeline == ROOT_TIMELINE + assert model.components[Ref("micro1")].timeline == Timeline(":macro") + assert model.components[Ref("micro2")].timeline == Timeline(":macro") + + +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) + model.components[Ref("B")].ports["out"].timeline = Timeline("A") + model.components[Ref("B")].ports["in"].timeline = Timeline("A") + resolve_timelines(model) + + +def test_model_ports(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("model_ports")] + resolve_timelines(model) diff --git a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl new file mode 100644 index 0000000..8ce6b22 --- /dev/null +++ b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl @@ -0,0 +1,225 @@ +ymmsl_version: v0.2 +description: Test models for determining timelines +models: + dispatch: + ports: + f_init: init + components: + first: + description: First component + ports: + f_init: init + o_f: final + second: + description: Second component + ports: + f_init: init + conduits: + init: first.init + first.final: second.init + + macromicro: + ports: + f_init: init + components: + macro: + description: Macro component + ports: + f_init: init + o_i: out + s: in + micro: + description: Micro component + ports: + f_init: init1 init2 + o_f: final + conduits: + init: + - macro.init + - repeat micro.init1 # Test repeater filter + macro.out: micro.init2 + micro.final: macro.in + + cycle: + components: + first: + description: First component + ports: + f_init: init + o_f: final + second: + description: Second component + ports: + f_init: init + o_f: final + third: + description: Third component + ports: + f_init: init + o_f: final + conduits: + first.final: second.init + second.final: third.init + third.final: first.init + + reducer: + components: + first: + description: First component + ports: + o_i: out + o_f: final + second: + description: Second component + ports: + f_init: init1 init2 + conduits: + first.final: second.init1 + first.out: last second.init2 + + inconsistent: + components: + macro: + description: Macro component + ports: + o_i: out + o_f: final + micro: + description: Micro component + ports: + f_init: in + o_f: final + inconsistent: + description: Component with inconsistent timelines + ports: + f_init: init1 init2 + conduits: + macro.out: micro.in + macro.final: inconsistent.init1 + micro.final: inconsistent.init2 + + repeaters: + ports: + f_init: init + components: + macro: + description: Macro component + ports: + f_init: init + o_i: out + s: in + meso: + description: Meso component + ports: + f_init: init init2 + o_i: out + s: in + o_f: final + micro: + description: Micro component + ports: + f_init: init init2 + o_f: final + conduits: + macro.out: meso.init + meso.final: macro.in + meso.out: micro.init + micro.final: meso.in + init: + - macro.init + - repeat meso.init2 + - pad pad micro.init2 + + repeater_reducer: + components: + macro1: + description: First macro component + ports: + o_i: out + o_f: final + micro1: + description: First micro + ports: + f_init: init + o_f: final + macro2: + description: Second macro component + ports: + f_init: init + o_i: out + micro2: + description: Second micro + ports: + f_init: init_micro1 init_macro2 + conduits: + macro1.out: micro1.init + macro1.final: macro2.init + macro2.out: micro2.init_macro2 + micro1.final: last repeat micro2.init_micro1 + + repeater_reducer_error: + components: + macro: + description: Macro component + ports: + o_i: out + micro1: + description: First micro + ports: + f_init: init + o_f: final + micro2: + description: Second micro + ports: + f_init: init1 init2 + conduits: + macro.out: + - micro1.init + - micro2.init1 + micro1.final: last pad micro2.init2 + + inconsistent_interact: + components: + A: + description: A + ports: + o_i: out + s: in + B: + description: A + ports: + o_i: out + s: in + conduits: + A.out: B.in + B.out: A.in + + model_ports: + components: + macro: + description: macro + ports: + f_init: init + o_i: out + s: in + o_f: final + meso: + description: meso + ports: + f_init: init + o_i: out + s: in + o_f: final + ports: + f_init: init + timeline macro:meso: + o_i: out + s: in + o_f: final + conduits: + init: macro.init + macro.out: meso.init + meso.out: out + in: meso.in + meso.final: macro.in + macro.final: final diff --git a/ymmsl/v0_2/timeline_resolver.py b/ymmsl/v0_2/timeline_resolver.py new file mode 100644 index 0000000..68cfd66 --- /dev/null +++ b/ymmsl/v0_2/timeline_resolver.py @@ -0,0 +1,314 @@ +from ymmsl.v0_2 import ( + Component, + Conduit, + Identifier, + Model, + Operator, + Port, + Reference, + Timeline, +) + +ROOT_TIMELINE = Timeline(":") + + +def resolve_timelines(model: Model) -> None: + """Determine timelines for each component and their O_I and S ports in this model. + + This function updates the timeline attributes of the components and ports in the + model. Any of the following exceptions (which are a subclass of + :class:`ResolveTimelineException`) will be raised when the model timelines are not + consistent. See their description for more details: + + - :class:`CyclicDependency` + - :class:`TooManyReducerFilters` + - :class:`InconsistentTimelines` + - :class:`ConduitTimelineError` + """ + checker = TimelineChecker(model) + checker.check_consistent() + + # Update timeline attributes + for component in model.components.values(): + component.timeline = checker.component_timeline(component.name) + for port in component.ports.values(): + full_port_name = component.name + port.name + timeline = checker.timeline_for_port(full_port_name) + port.timeline = timeline.relative_to(component.timeline) + + +class ResolveTimelineException(RuntimeError): + """Base class for exceptions raised while resolving timelines.""" + + +class CyclicDependency(ResolveTimelineException): + """Error raised when some models form a dependency cycle. + + Dependency cycles occur when messages to an F_INIT port of a component depend in + some way on the output of that component. + """ + + def __init__( + self, model: Model, cycle: list[Component], conduits: list[Conduit] + ) -> None: + self.model = model + self.cycle = cycle + self.conduits = conduits + cycle_str = " -> ".join(str(conduit) for conduit in reversed(conduits)) + super().__init__( + f"Detected a dependency cycle in model '{model.name}'. The component " + f"'{cycle[0].name}' has an F_INIT port that depends on data produced by " + f"one of its own O_F or O_I ports: {cycle_str}. You may have an error " + "in the conduits or may need a different coupling scheme." + ) + + +class TooManyReducerFilters(ResolveTimelineException): + """Error raised when a conduit has too many reducer filters applied. + + This error is raised when a reducer filter attempts to reduce a conduit that + already sends on the root timeline. :class:`InconsistentTimelines` or + :class:`ConduitTimelineError` may also be raised when too many reducer filters are + applied. + """ + + def __init__( + self, model: Model, conduit: Conduit, sender_timeline: Timeline + ) -> None: + self.model = model + self.conduit = conduit + self.sender_timeline = sender_timeline + num_reducers = sum(filter.is_reducer() for filter in conduit.filters) + msg = ( + f"{conduit} in model '{model.name}' has too many reducer filters. The " + f"sending port ({conduit.sender}) has timeline ({sender_timeline}) and " + f"can only be reduced {len(sender_timeline)} times, but there are " + f"{num_reducers} reducer filters." + ) + super().__init__(msg) + + +class InconsistentTimelines(ResolveTimelineException): + """Error raised when a component's F_INIT ports have inconsistent timelines. + + When a component has multiple F_INIT ports, each port should receive on the same + timeline. Note that :class:`ConduitTimelineError` may be raised for conduits + connected to F_INIT ports when they have a repeater filter. + """ + + def __init__( + self, + model: Model, + component: Component, + conduits: list[Conduit], + timelines: list[Timeline], + ) -> None: + self.model = model + self.component = component + self.conduits = conduits + self.timelines = timelines + msg = ( + f"Component '{component.name}' in model '{model.name}' has different " + f"timelines for the following F_INIT ports:\n" + + "\n".join( + f"- Port '{conduit.receiving_port()}' has timeline '{timeline}' " + f"from {conduit}" + for conduit, timeline in zip(conduits, timelines) + ) + ) + super().__init__(msg) + + +class ConduitTimelineError(ResolveTimelineException): + """Error raised for conduits that connect incompatible timelines.""" + + def __init__( + self, + checker: "TimelineChecker", + conduit: Conduit, + timeline1: Timeline, + timeline2: Timeline, + hint: str = "", + ) -> None: + self.checker = checker + model = checker._model + self.conduit = conduit + self.timeline1 = timeline1 + self.timeline2 = timeline2 + self.hint = hint + msg = ( + f"{conduit} in model '{model.name}' has inconsistent timelines: it " + f"connects timeline '{timeline1}' with timeline '{timeline2}', but this " + f"does not match with the filters of the conduit.{hint} Note that this " + "error may also be caused by missing timeline annotations for O_I and S " + "ports, or because the sending or receiving component has incorrect " + "F_INIT conduits. Determined timelines per component:\n" + f"{checker.format_timelines()}" + ) + super().__init__(msg) + + +class TimelineChecker: + """Checks timelines and nesting for a given yMMSL model""" + + def __init__(self, model: Model) -> None: + self._model = model + """yMMSL model that is checked.""" + + self._component_timeline: dict[Reference, Timeline] = { + Reference([]): ROOT_TIMELINE, # Support model ports + } + """Map of component names to the TimelineNode they are part of.""" + self._conduits_by_receiver: dict[Reference, Conduit] = { + conduit.receiver: conduit for conduit in self._model.conduits + } + """Map of conduits by their receiving port""" + self._all_ports: dict[Reference, Port] = { + component.name + port_name: port + for component in self._model.components.values() + for port_name, port in component.ports.items() + } + """Map of Port objects by their full reference""" + + # Assign components to timelines + for component in self._model.components.values(): + if component.name in self._component_timeline: + continue + self._assign_component(component, [], []) + + def _f_init_conduits_for_component(self, component: Component) -> list[Conduit]: + """Get conduits that are connected to an F_INIT port on the component""" + result = [] + for port in component.ports.values(): + if port.operator is Operator.F_INIT: + conduit = self._conduits_by_receiver.get(component.name + port.name) + if conduit is not None: + result.append(conduit) + return result + + def _assign_component( + self, component: Component, seen: list[Component], seen_conduits: list[Conduit] + ) -> None: + """Recursive component assignment, uses "seen" list for cycle detection.""" + if component in seen: + idx = seen.index(component) + cycle = seen[idx:] + [component] + raise CyclicDependency(self._model, cycle, seen_conduits[idx:]) + f_init_conduits = self._f_init_conduits_for_component(component) + + # Ensure we know the timelines of the components attached to our F_INIT + seen.append(component) + for conduit in f_init_conduits: + sender = conduit.sending_component() + if sender not in self._component_timeline: + seen_conduits.append(conduit) + self._assign_component( + self._model.components[sender], seen, seen_conduits + ) + seen_conduits.pop() + seen.pop() + + # Now we can determine our timeline + incoming_timelines: list[Timeline] = [] + checked_conduits: list[Conduit] = [] # To provide better error messages + for conduit in f_init_conduits: + if any(filter.is_repeater() for filter in conduit.filters): + continue # We cannot use repeater filters to determine the timeline + timeline = sender_timeline = self.timeline_for_port(conduit.sender) + for filter in conduit.filters: + assert filter.is_reducer() + if timeline.parent is None: + raise TooManyReducerFilters(self._model, conduit, sender_timeline) + timeline = timeline.parent + incoming_timelines.append(timeline) + checked_conduits.append(conduit) + + determined_timeline = ROOT_TIMELINE + if incoming_timelines: + determined_timeline = incoming_timelines[0] + if not all(tl == determined_timeline for tl in incoming_timelines): + raise InconsistentTimelines( + self._model, component, checked_conduits, incoming_timelines + ) + + # Done: register timeline for component + self._component_timeline[component.name] = determined_timeline + + def timeline_for_port(self, port_name: Reference) -> Timeline: + """Determine the timeline for messages sent or received on the provided port + name.""" + component = port_name[:-1] + if len(component) == 0: + # Connected to a model port + model_port = port_name[-1] + assert isinstance(model_port, Identifier) + port = self._model.ports[model_port] + return ROOT_TIMELINE + port.timeline + timeline = self._component_timeline[component] + port = self._all_ports[port_name] + if port.operator in (Operator.O_F, Operator.F_INIT): + return timeline + subtimeline = port.timeline + if len(port.timeline) == 0: + # No explicit label attached to the timeline, so we take the component name + subtimeline = Timeline(str(component)) + return timeline + subtimeline + + def component_timeline(self, component: Reference) -> Timeline: + """Get the determined timeline for a component in the model""" + return self._component_timeline[component] + + def check_consistent(self) -> None: + """Check if the timelines are consistent. + + N.B. Certain inconsistencies prevent initialization. This method performs + additional checks. + """ + # Check that all conduits connect consistently + for conduit in self._model.conduits: + timeline1 = self.timeline_for_port(conduit.sender) + timeline2 = self.timeline_for_port(conduit.receiver) + + num_reducers = sum(filter.is_reducer() for filter in conduit.filters) + num_repeaters = sum(filter.is_repeater() for filter in conduit.filters) + num_filters = len(conduit.filters) + assert num_reducers + num_repeaters == num_filters + + # Apply reducers + if len(timeline1) < num_reducers: + raise TooManyReducerFilters(self._model, conduit, timeline1) + if len(timeline1) - num_reducers + num_repeaters != len(timeline2): + remove_msg = "" + if len(timeline1) - num_reducers + num_repeaters < len(timeline2): + add_filter = "repeater ('pad' or 'repeat')" + if num_reducers > 0: + remove_msg = "reducer ('last')" + else: + add_filter = "reducer ('last')" + if num_repeaters > 0: + remove_msg = "repeater ('pad' or 'repeat')" + if remove_msg: + remove_msg = f" or remove a {remove_msg} filter" + hint = f" You may need to add a {add_filter} filter{remove_msg}." + raise ConduitTimelineError(self, conduit, timeline1, timeline2, hint) + + # Check consistency + common_idx = len(timeline1) - num_reducers + for idx, (part1, part2) in enumerate(zip(timeline1, timeline2)): + 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 + ) + + def format_timelines(self) -> str: + """Create a formatted list of determined timelines per component.""" + return "\n".join( + f"- Component '{comp}' has timeline '{tl}'" + for comp, tl in self._component_timeline.items() + if len(comp) > 0 # Ony print actual components + )