From 7fa9e870ef845b38f3cf36b6478690df3de7df70 Mon Sep 17 00:00:00 2001
From: Simon Bjurek <simbj106@student.liu.se>
Date: Thu, 20 Feb 2025 15:06:21 +0000
Subject: [PATCH] Added port constrain to list scheduler, added example and
 updated tests

---
 .gitlab-ci.yml                                |   2 +-
 b_asic/architecture.py                        |   7 +
 b_asic/core_schedulers.py                     |   9 +-
 b_asic/resources.py                           |   4 +-
 b_asic/scheduler.py                           | 107 +++--
 .../auto_scheduling_with_custom_io_times.py   |  13 +-
 examples/ldlt_matrix_inverse.py               |   5 +-
 examples/memory_constrained_scheduling.py     | 143 +++++++
 test/integration/test_sfg_to_architecture.py  | 157 ++++++++
 .../test_draw_matrix_transposer_4.png         | Bin
 .../test_draw_process_collection.png          | Bin
 .../test_left_edge_cell_assignment.png        | Bin
 .../test_max_min_lifetime_bar_plot.png        | Bin
 .../test__get_figure_no_execution_times.png   | Bin
 test/{ => unit}/test_architecture.py          |   2 +-
 test/{ => unit}/test_codegen.py               |   0
 test/{ => unit}/test_core_operations.py       |   0
 test/{ => unit}/test_core_schedulers.py       | 371 ++++++++++--------
 test/{ => unit}/test_graph_id_generator.py    |   0
 test/{ => unit}/test_gui.py                   |   0
 test/{ => unit}/test_gui/twotapfir.py         |   0
 test/{ => unit}/test_inputport.py             |   0
 test/{ => unit}/test_operation.py             |   0
 test/{ => unit}/test_outputport.py            |   0
 test/{ => unit}/test_process.py               |   0
 test/{ => unit}/test_quantization.py          |   0
 test/{ => unit}/test_resources.py             |   0
 test/{ => unit}/test_schedule.py              |   0
 test/{ => unit}/test_scheduler_gui.py         |   0
 test/{ => unit}/test_sfg.py                   |   0
 test/{ => unit}/test_sfg_generators.py        |   0
 test/{ => unit}/test_signal.py                |   0
 test/{ => unit}/test_signal_generator.py      |   0
 test/{ => unit}/test_signal_generator/bad.csv |   0
 .../test_signal_generator/input.csv           |  10 +-
 test/{ => unit}/test_simulation.py            |   0
 test/{ => unit}/test_simulation_gui.py        |   0
 test/{ => unit}/test_utils.py                 |   0
 38 files changed, 615 insertions(+), 215 deletions(-)
 create mode 100644 examples/memory_constrained_scheduling.py
 create mode 100644 test/integration/test_sfg_to_architecture.py
 rename test/{ => unit}/baseline_images/test_resources/test_draw_matrix_transposer_4.png (100%)
 rename test/{ => unit}/baseline_images/test_resources/test_draw_process_collection.png (100%)
 rename test/{ => unit}/baseline_images/test_resources/test_left_edge_cell_assignment.png (100%)
 rename test/{ => unit}/baseline_images/test_resources/test_max_min_lifetime_bar_plot.png (100%)
 rename test/{ => unit}/baseline_images/test_schedule/test__get_figure_no_execution_times.png (100%)
 rename test/{ => unit}/test_architecture.py (99%)
 rename test/{ => unit}/test_codegen.py (100%)
 rename test/{ => unit}/test_core_operations.py (100%)
 rename test/{ => unit}/test_core_schedulers.py (79%)
 rename test/{ => unit}/test_graph_id_generator.py (100%)
 rename test/{ => unit}/test_gui.py (100%)
 rename test/{ => unit}/test_gui/twotapfir.py (100%)
 rename test/{ => unit}/test_inputport.py (100%)
 rename test/{ => unit}/test_operation.py (100%)
 rename test/{ => unit}/test_outputport.py (100%)
 rename test/{ => unit}/test_process.py (100%)
 rename test/{ => unit}/test_quantization.py (100%)
 rename test/{ => unit}/test_resources.py (100%)
 rename test/{ => unit}/test_schedule.py (100%)
 rename test/{ => unit}/test_scheduler_gui.py (100%)
 rename test/{ => unit}/test_sfg.py (100%)
 rename test/{ => unit}/test_sfg_generators.py (100%)
 rename test/{ => unit}/test_signal.py (100%)
 rename test/{ => unit}/test_signal_generator.py (100%)
 rename test/{ => unit}/test_signal_generator/bad.csv (100%)
 rename test/{ => unit}/test_signal_generator/input.csv (66%)
 rename test/{ => unit}/test_simulation.py (100%)
 rename test/{ => unit}/test_simulation_gui.py (100%)
 rename test/{ => unit}/test_utils.py (100%)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8c3439a7..4a822db9 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -23,7 +23,7 @@ before_script:
 .run-test:
   stage: test
   script:
-    - pytest --cov=b_asic --cov-report=xml:cov.xml --cov-report=term --cov-branch --color=yes test --timeout=20 --durations=10
+    - pytest --cov=b_asic --cov-report=xml:cov.xml --cov-report=term --cov-branch --color=yes test --timeout=30 --durations=10
     # - lcov --capture --directory . --output-file coverage.info
     # - lcov --output-file coverage.info --extract coverage.info $PWD/src/'*' $PWD/b_asic/'*'
     # - lcov --list coverage.info
diff --git a/b_asic/architecture.py b/b_asic/architecture.py
index f55f7d8b..6e10d0f6 100644
--- a/b_asic/architecture.py
+++ b/b_asic/architecture.py
@@ -396,6 +396,13 @@ class ProcessingElement(Resource):
         assign: bool = True,
     ):
         super().__init__(process_collection=process_collection, entity_name=entity_name)
+
+        if not isinstance(process_collection, ProcessCollection):
+            raise TypeError(
+                "Argument process_collection must be ProcessCollection, "
+                f"not {type(process_collection)}"
+            )
+
         if not all(
             isinstance(operator, OperatorProcess)
             for operator in process_collection.collection
diff --git a/b_asic/core_schedulers.py b/b_asic/core_schedulers.py
index 32cb23db..cce720e3 100644
--- a/b_asic/core_schedulers.py
+++ b/b_asic/core_schedulers.py
@@ -124,8 +124,7 @@ class EarliestDeadlineScheduler(ListScheduler):
 
         deadlines = {}
         for op_id, start_time in schedule_copy.start_times.items():
-            if not op_id.startswith("in"):
-                deadlines[op_id] = start_time + schedule.sfg.find_by_id(op_id).latency
+            deadlines[op_id] = start_time + schedule.sfg.find_by_id(op_id).latency
 
         return sorted(deadlines, key=deadlines.get)
 
@@ -141,7 +140,7 @@ class LeastSlackTimeScheduler(ListScheduler):
         sorted_ops = sorted(
             schedule_copy.start_times, key=schedule_copy.start_times.get
         )
-        return [op for op in sorted_ops if not op.startswith("in")]
+        return sorted_ops
 
 
 class MaxFanOutScheduler(ListScheduler):
@@ -157,7 +156,7 @@ class MaxFanOutScheduler(ListScheduler):
             fan_outs[op_id] = len(schedule.sfg.find_by_id(op_id).output_signals)
 
         sorted_ops = sorted(fan_outs, key=fan_outs.get, reverse=True)
-        return [op for op in sorted_ops if not op.startswith("in")]
+        return sorted_ops
 
 
 class HybridScheduler(ListScheduler):
@@ -204,4 +203,4 @@ class HybridScheduler(ListScheduler):
 
         sorted_op_list = [pair[0] for pair in fan_out_sorted_items]
 
-        return [op for op in sorted_op_list if not op.startswith("in")]
+        return sorted_op_list
diff --git a/b_asic/resources.py b/b_asic/resources.py
index 97dffb68..597e7f29 100644
--- a/b_asic/resources.py
+++ b/b_asic/resources.py
@@ -877,7 +877,7 @@ class ProcessCollection:
 
         Parameters
         ----------
-        heuristic : {'graph_color', 'left_edge'}, default: 'graph_color'
+        heuristic : {'graph_color', 'left_edge'}, default: 'left_edge'
             The heuristic used when splitting based on execution times.
 
         coloring_strategy : str, default: 'saturation_largest_first'
@@ -919,7 +919,7 @@ class ProcessCollection:
 
         Parameters
         ----------
-        heuristic : str, default: "graph_color"
+        heuristic : str, default: "left_edge"
             The heuristic used when splitting this :class:`ProcessCollection`.
             Valid options are:
 
diff --git a/b_asic/scheduler.py b/b_asic/scheduler.py
index 1ecfbb2d..bf9a2474 100644
--- a/b_asic/scheduler.py
+++ b/b_asic/scheduler.py
@@ -1,3 +1,4 @@
+import sys
 from abc import ABC, abstractmethod
 from typing import TYPE_CHECKING, Optional, cast
 
@@ -9,6 +10,7 @@ from b_asic.types import TypeName
 if TYPE_CHECKING:
     from b_asic.operation import Operation
     from b_asic.schedule import Schedule
+    from b_asic.signal_flow_graph import SFG
     from b_asic.types import GraphID
 
 
@@ -48,6 +50,8 @@ class ListScheduler(Scheduler, ABC):
     def __init__(
         self,
         max_resources: Optional[dict[TypeName, int]] = None,
+        max_concurrent_reads: Optional[int] = None,
+        max_concurrent_writes: Optional[int] = None,
         input_times: Optional[dict["GraphID", int]] = None,
         output_delta_times: Optional[dict["GraphID", int]] = None,
         cyclic: Optional[bool] = False,
@@ -65,8 +69,11 @@ class ListScheduler(Scheduler, ABC):
         else:
             self._max_resources = {}
 
-        self._input_times = input_times if input_times else {}
-        self._output_delta_times = output_delta_times if output_delta_times else {}
+        self._max_concurrent_reads = max_concurrent_reads or sys.maxsize
+        self._max_concurrent_writes = max_concurrent_writes or sys.maxsize
+
+        self._input_times = input_times or {}
+        self._output_delta_times = output_delta_times or {}
 
     def apply_scheduling(self, schedule: "Schedule") -> None:
         """Applies the scheduling algorithm on the given Schedule.
@@ -77,60 +84,83 @@ class ListScheduler(Scheduler, ABC):
             Schedule to apply the scheduling algorithm on.
         """
         sfg = schedule.sfg
-        start_times = schedule.start_times
 
         used_resources_ready_times = {}
         remaining_resources = self._max_resources.copy()
+        if Input.type_name() not in remaining_resources:
+            remaining_resources[Input.type_name()] = 1
+        if Output.type_name() not in remaining_resources:
+            remaining_resources[Output.type_name()] = 1
+
         sorted_operations = self._get_sorted_operations(schedule)
 
+        schedule.start_times = {}
+
+        remaining_reads = self._max_concurrent_reads
+
         # initial input placement
         if self._input_times:
             for input_id in self._input_times:
-                start_times[input_id] = self._input_times[input_id]
-
-        for input_op in sfg.find_by_type_name(Input.type_name()):
-            if input_op.graph_id not in self._input_times:
-                start_times[input_op.graph_id] = 0
+                schedule.start_times[input_id] = self._input_times[input_id]
+            sorted_operations = [
+                elem for elem in sorted_operations if not elem.startswith("in")
+            ]
 
         current_time = 0
+        timeout_counter = 0
         while sorted_operations:
 
             # generate the best schedulable candidate
             candidate = sfg.find_by_id(sorted_operations[0])
             counter = 0
             while not self._candidate_is_schedulable(
-                start_times,
+                schedule.start_times,
+                sfg,
                 candidate,
                 current_time,
                 remaining_resources,
+                remaining_reads,
+                self._max_concurrent_writes,
                 sorted_operations,
             ):
                 if counter == len(sorted_operations):
                     counter = 0
                     current_time += 1
+                    timeout_counter += 1
+
+                    if timeout_counter > 10:
+                        msg = "Algorithm did not schedule any operation for 10 time steps, try relaxing constraints."
+                        raise TimeoutError(msg)
+
+                    remaining_reads = self._max_concurrent_reads
+
                     # update available operators
                     for operation, ready_time in used_resources_ready_times.items():
                         if ready_time == current_time:
                             remaining_resources[operation.type_name()] += 1
+
                 else:
                     candidate = sfg.find_by_id(sorted_operations[counter])
                     counter += 1
 
+            timeout_counter = 0
             # if the resource is constrained, update remaining resources
             if candidate.type_name() in remaining_resources:
                 remaining_resources[candidate.type_name()] -= 1
-                if candidate.execution_time:
-                    used_resources_ready_times[candidate] = (
-                        current_time + candidate.execution_time
-                    )
+                if (
+                    candidate.type_name() == Input.type_name()
+                    or candidate.type_name() == Output.type_name()
+                ):
+                    used_resources_ready_times[candidate] = current_time + 1
                 else:
                     used_resources_ready_times[candidate] = (
-                        current_time + candidate.latency
+                        current_time + candidate.execution_time
                     )
+            remaining_reads -= candidate.input_count
 
             # schedule the best candidate to the current time
             sorted_operations.remove(candidate.graph_id)
-            start_times[candidate.graph_id] = current_time
+            schedule.start_times[candidate.graph_id] = current_time
 
         self._handle_outputs(schedule)
 
@@ -138,12 +168,11 @@ class ListScheduler(Scheduler, ABC):
             max_start_time = max(schedule.start_times.values())
             if current_time < max_start_time:
                 current_time = max_start_time
+            current_time = max(current_time, schedule.get_max_end_time())
             schedule.set_schedule_time(current_time)
 
         schedule.remove_delays()
 
-        self._handle_inputs(schedule)
-
         # move all dont cares ALAP
         for dc_op in schedule.sfg.find_by_type_name(DontCare.type_name()):
             dc_op = cast(DontCare, dc_op)
@@ -152,9 +181,12 @@ class ListScheduler(Scheduler, ABC):
     @staticmethod
     def _candidate_is_schedulable(
         start_times: dict["GraphID"],
+        sfg: "SFG",
         operation: "Operation",
         current_time: int,
         remaining_resources: dict["GraphID", int],
+        remaining_reads: int,
+        max_concurrent_writes: int,
         remaining_ops: list["GraphID"],
     ) -> bool:
         if (
@@ -163,20 +195,43 @@ class ListScheduler(Scheduler, ABC):
         ):
             return False
 
+        op_finish_time = current_time + operation.latency
+        future_ops = [
+            sfg.find_by_id(item[0])
+            for item in start_times.items()
+            if item[1] + sfg.find_by_id(item[0]).latency == op_finish_time
+        ]
+
+        future_ops_writes = sum([op.input_count for op in future_ops])
+
+        if (
+            not operation.graph_id.startswith("out")
+            and future_ops_writes >= max_concurrent_writes
+        ):
+            return False
+
+        read_counter = 0
         earliest_start_time = 0
         for op_input in operation.inputs:
             source_op = op_input.signals[0].source.operation
+            if isinstance(source_op, Delay):
+                continue
+
             source_op_graph_id = source_op.graph_id
 
             if source_op_graph_id in remaining_ops:
                 return False
 
-            proceeding_op_start_time = start_times.get(source_op_graph_id)
+            if start_times[source_op_graph_id] != current_time - 1:
+                # not a direct connection -> memory read required
+                read_counter += 1
+
+            if read_counter > remaining_reads:
+                return False
 
-            if not isinstance(source_op, Delay):
-                earliest_start_time = max(
-                    earliest_start_time, proceeding_op_start_time + source_op.latency
-                )
+            proceeding_op_start_time = start_times.get(source_op_graph_id)
+            proceeding_op_finish_time = proceeding_op_start_time + source_op.latency
+            earliest_start_time = max(earliest_start_time, proceeding_op_finish_time)
 
         return earliest_start_time <= current_time
 
@@ -184,17 +239,9 @@ class ListScheduler(Scheduler, ABC):
     def _get_sorted_operations(schedule: "Schedule") -> list["GraphID"]:
         raise NotImplementedError
 
-    def _handle_inputs(self, schedule: "Schedule") -> None:
-        for input_op in schedule.sfg.find_by_type_name(Input.type_name()):
-            input_op = cast(Input, input_op)
-            if input_op.graph_id not in self._input_times:
-                schedule.move_operation_alap(input_op.graph_id)
-
     def _handle_outputs(
         self, schedule: "Schedule", non_schedulable_ops: Optional[list["GraphID"]] = []
     ) -> None:
-        super()._handle_outputs(schedule, non_schedulable_ops)
-
         schedule.set_schedule_time(schedule.get_max_end_time())
 
         for output in schedule.sfg.find_by_type_name(Output.type_name()):
diff --git a/examples/auto_scheduling_with_custom_io_times.py b/examples/auto_scheduling_with_custom_io_times.py
index 6b6a90b6..8913bfd8 100644
--- a/examples/auto_scheduling_with_custom_io_times.py
+++ b/examples/auto_scheduling_with_custom_io_times.py
@@ -52,7 +52,12 @@ output_delta_times = {
     "out7": 5,
 }
 schedule = Schedule(
-    sfg, scheduler=HybridScheduler(resources, input_times, output_delta_times)
+    sfg,
+    scheduler=HybridScheduler(
+        resources,
+        input_times=input_times,
+        output_delta_times=output_delta_times,
+    ),
 )
 schedule.show()
 
@@ -70,7 +75,11 @@ output_delta_times = {
 }
 schedule = Schedule(
     sfg,
-    scheduler=HybridScheduler(resources, input_times, output_delta_times),
+    scheduler=HybridScheduler(
+        resources,
+        input_times=input_times,
+        output_delta_times=output_delta_times,
+    ),
     cyclic=True,
 )
 schedule.show()
diff --git a/examples/ldlt_matrix_inverse.py b/examples/ldlt_matrix_inverse.py
index 4432e417..cf5961aa 100644
--- a/examples/ldlt_matrix_inverse.py
+++ b/examples/ldlt_matrix_inverse.py
@@ -84,7 +84,9 @@ output_delta_times = {
 }
 schedule = Schedule(
     sfg,
-    scheduler=HybridScheduler(resources, input_times, output_delta_times),
+    scheduler=HybridScheduler(
+        resources, input_times=input_times, output_delta_times=output_delta_times
+    ),
     cyclic=True,
 )
 print("Scheduling time:", schedule.schedule_time)
@@ -137,4 +139,3 @@ arch = Architecture(
 
 # %%
 arch
-# schedule.edit()
diff --git a/examples/memory_constrained_scheduling.py b/examples/memory_constrained_scheduling.py
new file mode 100644
index 00000000..c1f81fae
--- /dev/null
+++ b/examples/memory_constrained_scheduling.py
@@ -0,0 +1,143 @@
+"""
+=========================================
+Memory Constrained Scheduling
+=========================================
+
+"""
+
+from b_asic.architecture import Architecture, Memory, ProcessingElement
+from b_asic.core_operations import Butterfly, ConstantMultiplication
+from b_asic.core_schedulers import ASAPScheduler, HybridScheduler
+from b_asic.schedule import Schedule
+from b_asic.sfg_generators import radix_2_dif_fft
+from b_asic.special_operations import Input, Output
+
+sfg = radix_2_dif_fft(points=16)
+
+# %%
+# The SFG is
+sfg
+
+# %%
+# Set latencies and execution times.
+sfg.set_latency_of_type(Butterfly.type_name(), 3)
+sfg.set_latency_of_type(ConstantMultiplication.type_name(), 2)
+sfg.set_execution_time_of_type(Butterfly.type_name(), 1)
+sfg.set_execution_time_of_type(ConstantMultiplication.type_name(), 1)
+
+# # %%
+# Generate an ASAP schedule for reference
+schedule = Schedule(sfg, scheduler=ASAPScheduler())
+schedule.show()
+
+# %%
+# Generate a PE constrained HybridSchedule
+resources = {Butterfly.type_name(): 1, ConstantMultiplication.type_name(): 1}
+schedule = Schedule(sfg, scheduler=HybridScheduler(resources))
+schedule.show()
+
+# %% Print the max number of read and write port accesses to non-direct memories
+direct, mem_vars = schedule.get_memory_variables().split_on_length()
+print("Max read ports:", mem_vars.read_ports_bound())
+print("Max write ports:", mem_vars.write_ports_bound())
+
+# %%
+operations = schedule.get_operations()
+bfs = operations.get_by_type_name(Butterfly.type_name())
+bfs.show(title="Butterfly executions")
+const_muls = operations.get_by_type_name(ConstantMultiplication.type_name())
+const_muls.show(title="ConstMul executions")
+inputs = operations.get_by_type_name(Input.type_name())
+inputs.show(title="Input executions")
+outputs = operations.get_by_type_name(Output.type_name())
+outputs.show(title="Output executions")
+
+bf_pe = ProcessingElement(bfs, entity_name="bf")
+mul_pe = ProcessingElement(const_muls, entity_name="mul")
+
+pe_in = ProcessingElement(inputs, entity_name='input')
+pe_out = ProcessingElement(outputs, entity_name='output')
+
+mem_vars = schedule.get_memory_variables()
+mem_vars.show(title="All memory variables")
+direct, mem_vars = mem_vars.split_on_length()
+mem_vars.show(title="Non-zero time memory variables")
+mem_vars_set = mem_vars.split_on_ports(
+    read_ports=1, write_ports=1, total_ports=2, heuristic="graph_color"
+)
+
+# %%
+memories = []
+for i, mem in enumerate(mem_vars_set):
+    memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
+    memories.append(memory)
+    mem.show(title=f"{memory.entity_name}")
+    memory.assign("graph_color")
+    memory.show_content(title=f"Assigned {memory.entity_name}")
+
+direct.show(title="Direct interconnects")
+
+# %%
+arch = Architecture(
+    {bf_pe, mul_pe, pe_in, pe_out},
+    memories,
+    direct_interconnects=direct,
+)
+arch
+
+# %%
+# Generate another HybridSchedule but this time constrain the amount of reads and writes to reduce the amount of memories
+resources = {Butterfly.type_name(): 1, ConstantMultiplication.type_name(): 1}
+schedule = Schedule(
+    sfg,
+    scheduler=HybridScheduler(
+        resources, max_concurrent_reads=2, max_concurrent_writes=2
+    ),
+)
+schedule.show()
+
+# %% Print the max number of read and write port accesses to non-direct memories
+direct, mem_vars = schedule.get_memory_variables().split_on_length()
+print("Max read ports:", mem_vars.read_ports_bound())
+print("Max write ports:", mem_vars.write_ports_bound())
+
+# %% Proceed to construct PEs and plot executions and non-direct memory variables
+operations = schedule.get_operations()
+bfs = operations.get_by_type_name(Butterfly.type_name())
+bfs.show(title="Butterfly executions")
+const_muls = operations.get_by_type_name(ConstantMultiplication.type_name())
+const_muls.show(title="ConstMul executions")
+inputs = operations.get_by_type_name(Input.type_name())
+inputs.show(title="Input executions")
+outputs = operations.get_by_type_name(Output.type_name())
+outputs.show(title="Output executions")
+
+bf_pe = ProcessingElement(bfs, entity_name="bf")
+mul_pe = ProcessingElement(const_muls, entity_name="mul")
+
+pe_in = ProcessingElement(inputs, entity_name='input')
+pe_out = ProcessingElement(outputs, entity_name='output')
+
+mem_vars.show(title="Non-zero time memory variables")
+mem_vars_set = mem_vars.split_on_ports(
+    read_ports=1, write_ports=1, total_ports=2, heuristic="graph_color"
+)
+
+# %% Allocate memories by graph coloring
+memories = []
+for i, mem in enumerate(mem_vars_set):
+    memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
+    memories.append(memory)
+    mem.show(title=f"{memory.entity_name}")
+    memory.assign("graph_color")
+    memory.show_content(title=f"Assigned {memory.entity_name}")
+
+direct.show(title="Direct interconnects")
+
+# %% Synthesize the new architecture, now only using two memories but with data rate
+arch = Architecture(
+    {bf_pe, mul_pe, pe_in, pe_out},
+    memories,
+    direct_interconnects=direct,
+)
+arch
diff --git a/test/integration/test_sfg_to_architecture.py b/test/integration/test_sfg_to_architecture.py
new file mode 100644
index 00000000..c07e9766
--- /dev/null
+++ b/test/integration/test_sfg_to_architecture.py
@@ -0,0 +1,157 @@
+import pytest
+
+from b_asic.architecture import Architecture, Memory, ProcessingElement
+from b_asic.core_operations import (
+    MADS,
+    Butterfly,
+    ConstantMultiplication,
+    DontCare,
+    Reciprocal,
+)
+from b_asic.core_schedulers import ASAPScheduler, HybridScheduler
+from b_asic.schedule import Schedule
+from b_asic.sfg_generators import ldlt_matrix_inverse, radix_2_dif_fft
+from b_asic.special_operations import Input, Output
+
+
+def test_pe_constrained_schedule():
+    sfg = ldlt_matrix_inverse(N=5)
+
+    sfg.set_latency_of_type(MADS.type_name(), 3)
+    sfg.set_latency_of_type(Reciprocal.type_name(), 2)
+    sfg.set_execution_time_of_type(MADS.type_name(), 1)
+    sfg.set_execution_time_of_type(Reciprocal.type_name(), 1)
+
+    resources = {MADS.type_name(): 2, Reciprocal.type_name(): 1}
+
+    # Generate a schedule to ensure that schedule can be overwritten without bugs
+    schedule = Schedule(sfg, scheduler=ASAPScheduler())
+
+    schedule = Schedule(sfg, scheduler=HybridScheduler(resources))
+
+    direct, mem_vars = schedule.get_memory_variables().split_on_length()
+    assert mem_vars.read_ports_bound() <= 7
+    assert mem_vars.write_ports_bound() <= 4
+
+    operations = schedule.get_operations()
+
+    with pytest.raises(
+        TypeError, match="Different Operation types in ProcessCollection"
+    ):
+        ProcessingElement(operations)
+
+    mads = operations.get_by_type_name(MADS.type_name())
+    with pytest.raises(
+        ValueError, match="Cannot map ProcessCollection to single ProcessingElement"
+    ):
+        ProcessingElement(mads, entity_name="mad")
+    mads = mads.split_on_execution_time()
+    with pytest.raises(
+        TypeError,
+        match="Argument process_collection must be ProcessCollection, not <class 'list'>",
+    ):
+        ProcessingElement(mads, entity_name="mad")
+
+    assert len(mads) == 2
+
+    reciprocals = operations.get_by_type_name(Reciprocal.type_name())
+    dont_cares = operations.get_by_type_name(DontCare.type_name())
+    inputs = operations.get_by_type_name(Input.type_name())
+    outputs = operations.get_by_type_name(Output.type_name())
+
+    mads0 = ProcessingElement(mads[0], entity_name="mads0")
+    mads1 = ProcessingElement(mads[1], entity_name="mads1")
+    reciprocal_pe = ProcessingElement(reciprocals, entity_name="rec")
+
+    dont_care_pe = ProcessingElement(dont_cares, entity_name="dc")
+
+    pe_in = ProcessingElement(inputs, entity_name='input')
+    pe_out = ProcessingElement(outputs, entity_name='output')
+
+    mem_vars_set = mem_vars.split_on_ports(read_ports=1, write_ports=1, total_ports=2)
+    memories = []
+    for i, mem in enumerate(mem_vars_set):
+        memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
+        memories.append(memory)
+        memory.assign("graph_color")
+
+    arch = Architecture(
+        {mads0, mads1, reciprocal_pe, dont_care_pe, pe_in, pe_out},
+        memories,
+        direct_interconnects=direct,
+    )
+
+    assert len(arch.memories) == len(memories)
+    for i in range(len(memories)):
+        assert arch.memories[i] == memories[i]
+
+    assert len(arch.processing_elements) == 6
+
+    assert arch.direct_interconnects == direct
+
+    assert arch.schedule_time == schedule.schedule_time
+
+
+def test_pe_and_memory_constrained_chedule():
+    sfg = radix_2_dif_fft(points=16)
+
+    sfg.set_latency_of_type(Butterfly.type_name(), 3)
+    sfg.set_latency_of_type(ConstantMultiplication.type_name(), 2)
+    sfg.set_execution_time_of_type(Butterfly.type_name(), 1)
+    sfg.set_execution_time_of_type(ConstantMultiplication.type_name(), 1)
+
+    # generate a schedule to ensure that schedule can be overwritten without bugs
+    schedule = Schedule(sfg, scheduler=ASAPScheduler())
+
+    # generate the real constrained schedule
+    resources = {Butterfly.type_name(): 1, ConstantMultiplication.type_name(): 1}
+    schedule = Schedule(
+        sfg,
+        scheduler=HybridScheduler(
+            resources, max_concurrent_reads=2, max_concurrent_writes=2
+        ),
+    )
+
+    operations = schedule.get_operations()
+    bfs = operations.get_by_type_name(Butterfly.type_name())
+    const_muls = operations.get_by_type_name(ConstantMultiplication.type_name())
+    inputs = operations.get_by_type_name(Input.type_name())
+    outputs = operations.get_by_type_name(Output.type_name())
+
+    bf_pe = ProcessingElement(bfs, entity_name="bf")
+    mul_pe = ProcessingElement(const_muls, entity_name="mul")
+
+    pe_in = ProcessingElement(inputs, entity_name='input')
+    pe_out = ProcessingElement(outputs, entity_name='output')
+
+    mem_vars = schedule.get_memory_variables()
+    direct, mem_vars = mem_vars.split_on_length()
+    mem_vars_set = mem_vars.split_on_ports(
+        read_ports=1, write_ports=1, total_ports=2, heuristic="graph_color"
+    )
+
+    mem_vars_set = mem_vars.split_on_ports(
+        read_ports=1, write_ports=1, total_ports=2, heuristic="graph_color"
+    )
+
+    memories = []
+    for i, mem in enumerate(mem_vars_set):
+        memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
+        memories.append(memory)
+        memory.assign("graph_color")
+
+    arch = Architecture(
+        {bf_pe, mul_pe, pe_in, pe_out},
+        memories,
+        direct_interconnects=direct,
+    )
+
+    assert len(arch.memories) == 2
+    assert arch.memories[0] == memories[0]
+    assert arch.memories[1] == memories[1]
+
+    assert len(arch.processing_elements) == 4
+
+    assert arch.direct_interconnects == direct
+
+    assert arch.schedule_time == schedule.schedule_time
diff --git a/test/baseline_images/test_resources/test_draw_matrix_transposer_4.png b/test/unit/baseline_images/test_resources/test_draw_matrix_transposer_4.png
similarity index 100%
rename from test/baseline_images/test_resources/test_draw_matrix_transposer_4.png
rename to test/unit/baseline_images/test_resources/test_draw_matrix_transposer_4.png
diff --git a/test/baseline_images/test_resources/test_draw_process_collection.png b/test/unit/baseline_images/test_resources/test_draw_process_collection.png
similarity index 100%
rename from test/baseline_images/test_resources/test_draw_process_collection.png
rename to test/unit/baseline_images/test_resources/test_draw_process_collection.png
diff --git a/test/baseline_images/test_resources/test_left_edge_cell_assignment.png b/test/unit/baseline_images/test_resources/test_left_edge_cell_assignment.png
similarity index 100%
rename from test/baseline_images/test_resources/test_left_edge_cell_assignment.png
rename to test/unit/baseline_images/test_resources/test_left_edge_cell_assignment.png
diff --git a/test/baseline_images/test_resources/test_max_min_lifetime_bar_plot.png b/test/unit/baseline_images/test_resources/test_max_min_lifetime_bar_plot.png
similarity index 100%
rename from test/baseline_images/test_resources/test_max_min_lifetime_bar_plot.png
rename to test/unit/baseline_images/test_resources/test_max_min_lifetime_bar_plot.png
diff --git a/test/baseline_images/test_schedule/test__get_figure_no_execution_times.png b/test/unit/baseline_images/test_schedule/test__get_figure_no_execution_times.png
similarity index 100%
rename from test/baseline_images/test_schedule/test__get_figure_no_execution_times.png
rename to test/unit/baseline_images/test_schedule/test__get_figure_no_execution_times.png
diff --git a/test/test_architecture.py b/test/unit/test_architecture.py
similarity index 99%
rename from test/test_architecture.py
rename to test/unit/test_architecture.py
index 6b72bbdc..432f0652 100644
--- a/test/test_architecture.py
+++ b/test/unit/test_architecture.py
@@ -187,7 +187,7 @@ def test_move_process(schedule_direct_form_iir_lp_filter: Schedule):
     mvs = schedule_direct_form_iir_lp_filter.get_memory_variables()
     operations = schedule_direct_form_iir_lp_filter.get_operations()
     adders1, adders2 = operations.get_by_type_name(Addition.type_name()).split_on_ports(
-        total_ports=1
+        heuristic="left_edge", total_ports=1
     )
     adders1 = [adders1]  # Fake two PEs needed for the adders
     adders2 = [adders2]  # Fake two PEs needed for the adders
diff --git a/test/test_codegen.py b/test/unit/test_codegen.py
similarity index 100%
rename from test/test_codegen.py
rename to test/unit/test_codegen.py
diff --git a/test/test_core_operations.py b/test/unit/test_core_operations.py
similarity index 100%
rename from test/test_core_operations.py
rename to test/unit/test_core_operations.py
diff --git a/test/test_core_schedulers.py b/test/unit/test_core_schedulers.py
similarity index 79%
rename from test/test_core_schedulers.py
rename to test/unit/test_core_schedulers.py
index 29697486..755f5cc6 100644
--- a/test/test_core_schedulers.py
+++ b/test/unit/test_core_schedulers.py
@@ -1,3 +1,5 @@
+import sys
+
 import pytest
 
 from b_asic.core_operations import (
@@ -21,6 +23,7 @@ from b_asic.sfg_generators import (
     ldlt_matrix_inverse,
     radix_2_dif_fft,
 )
+from b_asic.special_operations import Input, Output
 
 
 class TestASAPScheduler:
@@ -298,15 +301,20 @@ class TestEarliestDeadlineScheduler:
         sfg.set_latency_of_type(Addition.type_name(), 3)
         sfg.set_execution_time_of_type(Addition.type_name(), 1)
 
-        resources = {Addition.type_name(): 1, ConstantMultiplication.type_name(): 1}
+        resources = {
+            Addition.type_name(): 1,
+            ConstantMultiplication.type_name(): 1,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
         schedule = Schedule(
             sfg, scheduler=EarliestDeadlineScheduler(max_resources=resources)
         )
 
         assert schedule.start_times == {
+            "in0": 0,
             "cmul4": 0,
             "cmul3": 1,
-            "in0": 2,
             "cmul0": 2,
             "add1": 3,
             "cmul1": 3,
@@ -318,67 +326,7 @@ class TestEarliestDeadlineScheduler:
         }
         assert schedule.schedule_time == 13
 
-    def test_direct_form_2_iir_inf_resources_no_exec_time(
-        self, sfg_direct_form_iir_lp_filter
-    ):
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(Addition.type_name(), 5)
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(
-            ConstantMultiplication.type_name(), 4
-        )
-
-        schedule = Schedule(
-            sfg_direct_form_iir_lp_filter, scheduler=EarliestDeadlineScheduler()
-        )
-
-        # should be the same as for ASAP due to infinite resources, except for input
-        assert schedule.start_times == {
-            "in0": 9,
-            "cmul1": 0,
-            "cmul4": 0,
-            "cmul2": 0,
-            "cmul3": 0,
-            "add3": 4,
-            "add1": 4,
-            "add0": 9,
-            "cmul0": 14,
-            "add2": 18,
-            "out0": 23,
-        }
-        assert schedule.schedule_time == 23
-
-    def test_direct_form_2_iir_1_add_1_mul_no_exec_time(
-        self, sfg_direct_form_iir_lp_filter
-    ):
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(Addition.type_name(), 5)
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(
-            ConstantMultiplication.type_name(), 4
-        )
-
-        max_resources = {ConstantMultiplication.type_name(): 1, Addition.type_name(): 1}
-
-        schedule = Schedule(
-            sfg_direct_form_iir_lp_filter,
-            scheduler=EarliestDeadlineScheduler(max_resources),
-        )
-        assert schedule.start_times == {
-            "cmul4": 0,
-            "cmul3": 4,
-            "cmul1": 8,
-            "add1": 8,
-            "cmul2": 12,
-            "in0": 13,
-            "add0": 13,
-            "add3": 18,
-            "cmul0": 18,
-            "add2": 23,
-            "out0": 28,
-        }
-
-        assert schedule.schedule_time == 28
-
-    def test_direct_form_2_iir_1_add_1_mul_exec_time_1(
-        self, sfg_direct_form_iir_lp_filter
-    ):
+    def test_direct_form_2_iir_1_add_1_mul(self, sfg_direct_form_iir_lp_filter):
         sfg_direct_form_iir_lp_filter.set_latency_of_type(
             ConstantMultiplication.type_name(), 3
         )
@@ -390,19 +338,24 @@ class TestEarliestDeadlineScheduler:
             Addition.type_name(), 1
         )
 
-        max_resources = {ConstantMultiplication.type_name(): 1, Addition.type_name(): 1}
+        resources = {
+            Addition.type_name(): 1,
+            ConstantMultiplication.type_name(): 1,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
 
         schedule = Schedule(
             sfg_direct_form_iir_lp_filter,
-            scheduler=EarliestDeadlineScheduler(max_resources),
+            scheduler=EarliestDeadlineScheduler(resources),
         )
         assert schedule.start_times == {
+            "in0": 0,
             "cmul4": 0,
             "cmul3": 1,
             "cmul1": 2,
             "cmul2": 3,
             "add1": 4,
-            "in0": 6,
             "add0": 6,
             "add3": 7,
             "cmul0": 8,
@@ -412,9 +365,7 @@ class TestEarliestDeadlineScheduler:
 
         assert schedule.schedule_time == 13
 
-    def test_direct_form_2_iir_2_add_3_mul_exec_time_1(
-        self, sfg_direct_form_iir_lp_filter
-    ):
+    def test_direct_form_2_iir_2_add_3_mul(self, sfg_direct_form_iir_lp_filter):
         sfg_direct_form_iir_lp_filter.set_latency_of_type(
             ConstantMultiplication.type_name(), 3
         )
@@ -426,20 +377,25 @@ class TestEarliestDeadlineScheduler:
             Addition.type_name(), 1
         )
 
-        max_resources = {ConstantMultiplication.type_name(): 3, Addition.type_name(): 2}
+        resources = {
+            Addition.type_name(): 2,
+            ConstantMultiplication.type_name(): 3,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
 
         schedule = Schedule(
             sfg_direct_form_iir_lp_filter,
-            scheduler=EarliestDeadlineScheduler(max_resources),
+            scheduler=EarliestDeadlineScheduler(resources),
         )
         assert schedule.start_times == {
+            "in0": 0,
             "cmul1": 0,
             "cmul4": 0,
             "cmul3": 0,
             "cmul2": 1,
             "add1": 3,
             "add3": 4,
-            "in0": 5,
             "add0": 5,
             "cmul0": 7,
             "add2": 10,
@@ -456,26 +412,31 @@ class TestEarliestDeadlineScheduler:
         sfg.set_latency_of_type(Butterfly.type_name(), 1)
         sfg.set_execution_time_of_type(Butterfly.type_name(), 1)
 
-        resources = {Butterfly.type_name(): 2, ConstantMultiplication.type_name(): 2}
+        resources = {
+            Butterfly.type_name(): 2,
+            ConstantMultiplication.type_name(): 2,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
         schedule = Schedule(
             sfg, scheduler=EarliestDeadlineScheduler(max_resources=resources)
         )
 
         assert schedule.start_times == {
+            "in0": 0,
             "in1": 0,
+            "in2": 0,
             "in3": 0,
+            "in4": 0,
             "in5": 0,
+            "in6": 0,
             "in7": 0,
             "bfly6": 0,
             "bfly8": 0,
-            "in2": 1,
-            "in6": 1,
             "cmul2": 1,
             "cmul3": 1,
             "bfly11": 1,
             "bfly7": 1,
-            "in0": 2,
-            "in4": 2,
             "cmul0": 2,
             "bfly0": 2,
             "cmul4": 2,
@@ -514,15 +475,20 @@ class TestLeastSlackTimeScheduler:
         sfg.set_latency_of_type(Addition.type_name(), 3)
         sfg.set_execution_time_of_type(Addition.type_name(), 1)
 
-        resources = {Addition.type_name(): 1, ConstantMultiplication.type_name(): 1}
+        resources = {
+            Addition.type_name(): 1,
+            ConstantMultiplication.type_name(): 1,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
         schedule = Schedule(
             sfg, scheduler=LeastSlackTimeScheduler(max_resources=resources)
         )
 
         assert schedule.start_times == {
+            "in0": 0,
             "cmul4": 0,
             "cmul3": 1,
-            "in0": 2,
             "cmul0": 2,
             "add1": 3,
             "cmul1": 3,
@@ -534,67 +500,7 @@ class TestLeastSlackTimeScheduler:
         }
         assert schedule.schedule_time == 13
 
-    def test_direct_form_2_iir_inf_resources_no_exec_time(
-        self, sfg_direct_form_iir_lp_filter
-    ):
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(Addition.type_name(), 5)
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(
-            ConstantMultiplication.type_name(), 4
-        )
-
-        schedule = Schedule(
-            sfg_direct_form_iir_lp_filter, scheduler=LeastSlackTimeScheduler()
-        )
-
-        # should be the same as for ASAP due to infinite resources, except for input
-        assert schedule.start_times == {
-            "in0": 9,
-            "cmul1": 0,
-            "cmul4": 0,
-            "cmul2": 0,
-            "cmul3": 0,
-            "add3": 4,
-            "add1": 4,
-            "add0": 9,
-            "cmul0": 14,
-            "add2": 18,
-            "out0": 23,
-        }
-        assert schedule.schedule_time == 23
-
-    def test_direct_form_2_iir_1_add_1_mul_no_exec_time(
-        self, sfg_direct_form_iir_lp_filter
-    ):
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(Addition.type_name(), 5)
-        sfg_direct_form_iir_lp_filter.set_latency_of_type(
-            ConstantMultiplication.type_name(), 4
-        )
-
-        max_resources = {ConstantMultiplication.type_name(): 1, Addition.type_name(): 1}
-
-        schedule = Schedule(
-            sfg_direct_form_iir_lp_filter,
-            scheduler=LeastSlackTimeScheduler(max_resources),
-        )
-        assert schedule.start_times == {
-            "cmul4": 0,
-            "cmul3": 4,
-            "cmul1": 8,
-            "add1": 8,
-            "cmul2": 12,
-            "in0": 13,
-            "add0": 13,
-            "add3": 18,
-            "cmul0": 18,
-            "add2": 23,
-            "out0": 28,
-        }
-
-        assert schedule.schedule_time == 28
-
-    def test_direct_form_2_iir_1_add_1_mul_exec_time_1(
-        self, sfg_direct_form_iir_lp_filter
-    ):
+    def test_direct_form_2_iir_1_add_1_mul(self, sfg_direct_form_iir_lp_filter):
         sfg_direct_form_iir_lp_filter.set_latency_of_type(
             ConstantMultiplication.type_name(), 3
         )
@@ -606,19 +512,24 @@ class TestLeastSlackTimeScheduler:
             Addition.type_name(), 1
         )
 
-        max_resources = {ConstantMultiplication.type_name(): 1, Addition.type_name(): 1}
+        resources = {
+            Addition.type_name(): 1,
+            ConstantMultiplication.type_name(): 1,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
 
         schedule = Schedule(
             sfg_direct_form_iir_lp_filter,
-            scheduler=LeastSlackTimeScheduler(max_resources),
+            scheduler=LeastSlackTimeScheduler(resources),
         )
         assert schedule.start_times == {
+            "in0": 0,
             "cmul4": 0,
             "cmul3": 1,
             "cmul1": 2,
             "cmul2": 3,
             "add1": 4,
-            "in0": 6,
             "add0": 6,
             "add3": 7,
             "cmul0": 8,
@@ -628,9 +539,7 @@ class TestLeastSlackTimeScheduler:
 
         assert schedule.schedule_time == 13
 
-    def test_direct_form_2_iir_2_add_3_mul_exec_time_1(
-        self, sfg_direct_form_iir_lp_filter
-    ):
+    def test_direct_form_2_iir_2_add_3_mul(self, sfg_direct_form_iir_lp_filter):
         sfg_direct_form_iir_lp_filter.set_latency_of_type(
             ConstantMultiplication.type_name(), 3
         )
@@ -642,20 +551,25 @@ class TestLeastSlackTimeScheduler:
             Addition.type_name(), 1
         )
 
-        max_resources = {ConstantMultiplication.type_name(): 3, Addition.type_name(): 2}
+        resources = {
+            Addition.type_name(): 2,
+            ConstantMultiplication.type_name(): 3,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
 
         schedule = Schedule(
             sfg_direct_form_iir_lp_filter,
-            scheduler=LeastSlackTimeScheduler(max_resources),
+            scheduler=LeastSlackTimeScheduler(resources),
         )
         assert schedule.start_times == {
+            "in0": 0,
             "cmul1": 0,
             "cmul4": 0,
             "cmul3": 0,
             "cmul2": 1,
             "add1": 3,
             "add3": 4,
-            "in0": 5,
             "add0": 5,
             "cmul0": 7,
             "add2": 10,
@@ -672,26 +586,31 @@ class TestLeastSlackTimeScheduler:
         sfg.set_latency_of_type(Butterfly.type_name(), 1)
         sfg.set_execution_time_of_type(Butterfly.type_name(), 1)
 
-        resources = {Butterfly.type_name(): 2, ConstantMultiplication.type_name(): 2}
+        resources = {
+            Butterfly.type_name(): 2,
+            ConstantMultiplication.type_name(): 2,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
         schedule = Schedule(
             sfg, scheduler=LeastSlackTimeScheduler(max_resources=resources)
         )
 
         assert schedule.start_times == {
+            "in0": 0,
             "in1": 0,
+            "in2": 0,
             "in3": 0,
+            "in4": 0,
             "in5": 0,
+            "in6": 0,
             "in7": 0,
             "bfly6": 0,
             "bfly8": 0,
-            "in2": 1,
-            "in6": 1,
             "cmul2": 1,
             "cmul3": 1,
             "bfly11": 1,
             "bfly7": 1,
-            "in0": 2,
-            "in4": 2,
             "cmul0": 2,
             "bfly0": 2,
             "cmul4": 2,
@@ -768,9 +687,9 @@ class TestHybridScheduler:
         schedule = Schedule(sfg, scheduler=HybridScheduler(max_resources=resources))
 
         assert schedule.start_times == {
+            "in0": 0,
             "cmul4": 0,
             "cmul3": 1,
-            "in0": 2,
             "cmul0": 2,
             "add1": 3,
             "cmul1": 3,
@@ -790,24 +709,29 @@ class TestHybridScheduler:
         sfg.set_latency_of_type(Butterfly.type_name(), 1)
         sfg.set_execution_time_of_type(Butterfly.type_name(), 1)
 
-        resources = {Butterfly.type_name(): 2, ConstantMultiplication.type_name(): 2}
+        resources = {
+            Butterfly.type_name(): 2,
+            ConstantMultiplication.type_name(): 2,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
         schedule = Schedule(sfg, scheduler=HybridScheduler(max_resources=resources))
 
         assert schedule.start_times == {
+            "in0": 0,
             "in1": 0,
+            "in2": 0,
             "in3": 0,
+            "in4": 0,
             "in5": 0,
+            "in6": 0,
             "in7": 0,
             "bfly6": 0,
             "bfly8": 0,
-            "in2": 1,
-            "in6": 1,
             "cmul2": 1,
             "cmul3": 1,
             "bfly11": 1,
             "bfly7": 1,
-            "in0": 2,
-            "in4": 2,
             "cmul0": 2,
             "bfly0": 2,
             "cmul4": 2,
@@ -830,6 +754,59 @@ class TestHybridScheduler:
         }
         assert schedule.schedule_time == 7
 
+    def test_radix_2_fft_8_points_one_output(self):
+        sfg = radix_2_dif_fft(points=8)
+
+        sfg.set_latency_of_type(ConstantMultiplication.type_name(), 2)
+        sfg.set_execution_time_of_type(ConstantMultiplication.type_name(), 1)
+        sfg.set_latency_of_type(Butterfly.type_name(), 1)
+        sfg.set_execution_time_of_type(Butterfly.type_name(), 1)
+
+        resources = {
+            Butterfly.type_name(): 2,
+            ConstantMultiplication.type_name(): 2,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): 1,
+        }
+        schedule = Schedule(sfg, scheduler=HybridScheduler(max_resources=resources))
+
+        assert schedule.start_times == {
+            "in0": 0,
+            "in1": 0,
+            "in2": 0,
+            "in3": 0,
+            "in4": 0,
+            "in5": 0,
+            "in6": 0,
+            "in7": 0,
+            "bfly6": 0,
+            "bfly8": 0,
+            "cmul2": 1,
+            "cmul3": 1,
+            "bfly11": 1,
+            "bfly7": 1,
+            "cmul0": 2,
+            "bfly0": 2,
+            "cmul4": 2,
+            "bfly5": 3,
+            "bfly1": 3,
+            "cmul1": 4,
+            "bfly2": 4,
+            "bfly9": 4,
+            "bfly10": 5,
+            "bfly3": 5,
+            "out0": 5,
+            "bfly4": 6,
+            "out1": 6,
+            "out2": 12,
+            "out3": 11,
+            "out4": 10,
+            "out5": 9,
+            "out6": 8,
+            "out7": 7,
+        }
+        assert schedule.schedule_time == 12
+
     def test_radix_2_fft_8_points_specified_IO_times_cyclic(self):
         sfg = radix_2_dif_fft(points=8)
 
@@ -838,7 +815,12 @@ class TestHybridScheduler:
         sfg.set_execution_time_of_type(Butterfly.type_name(), 1)
         sfg.set_execution_time_of_type(ConstantMultiplication.type_name(), 1)
 
-        resources = {Butterfly.type_name(): 1, ConstantMultiplication.type_name(): 1}
+        resources = {
+            Butterfly.type_name(): 1,
+            ConstantMultiplication.type_name(): 1,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
         input_times = {
             "in0": 0,
             "in1": 1,
@@ -861,7 +843,9 @@ class TestHybridScheduler:
         }
         schedule = Schedule(
             sfg,
-            scheduler=HybridScheduler(resources, input_times, output_times),
+            scheduler=HybridScheduler(
+                resources, input_times=input_times, output_delta_times=output_times
+            ),
             cyclic=True,
         )
 
@@ -933,7 +917,9 @@ class TestHybridScheduler:
         }
         schedule = Schedule(
             sfg,
-            scheduler=HybridScheduler(resources, input_times, output_times),
+            scheduler=HybridScheduler(
+                resources, input_times=input_times, output_delta_times=output_times
+            ),
             cyclic=False,
         )
 
@@ -982,7 +968,12 @@ class TestHybridScheduler:
         sfg.set_execution_time_of_type(MADS.type_name(), 1)
         sfg.set_execution_time_of_type(Reciprocal.type_name(), 1)
 
-        resources = {MADS.type_name(): 1, Reciprocal.type_name(): 1}
+        resources = {
+            MADS.type_name(): 1,
+            Reciprocal.type_name(): 1,
+            Input.type_name(): sys.maxsize,
+            Output.type_name(): sys.maxsize,
+        }
         schedule = Schedule(
             sfg,
             scheduler=HybridScheduler(resources),
@@ -990,11 +981,11 @@ class TestHybridScheduler:
 
         assert schedule.start_times == {
             "in0": 0,
+            "in1": 0,
+            "in2": 0,
             "rec0": 0,
-            "in1": 2,
             "dontcare1": 2,
             "mads0": 2,
-            "in2": 5,
             "mads3": 5,
             "rec1": 8,
             "dontcare0": 10,
@@ -1027,7 +1018,9 @@ class TestHybridScheduler:
         }
         schedule = Schedule(
             sfg,
-            scheduler=HybridScheduler(resources, input_times, output_times),
+            scheduler=HybridScheduler(
+                resources, input_times=input_times, output_delta_times=output_times
+            ),
             cyclic=True,
         )
 
@@ -1078,3 +1071,47 @@ class TestHybridScheduler:
         resources = {MADS.type_name(): "test"}
         with pytest.raises(ValueError, match="max_resources value must be an integer."):
             Schedule(sfg, scheduler=HybridScheduler(resources))
+
+    def test_ldlt_inverse_3x3_read_and_write_constrained(self):
+        sfg = ldlt_matrix_inverse(N=3)
+
+        sfg.set_latency_of_type(MADS.type_name(), 3)
+        sfg.set_latency_of_type(Reciprocal.type_name(), 2)
+        sfg.set_execution_time_of_type(MADS.type_name(), 1)
+        sfg.set_execution_time_of_type(Reciprocal.type_name(), 1)
+
+        resources = {MADS.type_name(): 1, Reciprocal.type_name(): 1}
+
+        schedule = Schedule(
+            sfg,
+            scheduler=HybridScheduler(
+                max_resources=resources,
+                max_concurrent_reads=3,
+                max_concurrent_writes=1,
+            ),
+        )
+
+        direct, mem_vars = schedule.get_memory_variables().split_on_length()
+        assert mem_vars.read_ports_bound() == 3
+        assert mem_vars.write_ports_bound() == 1
+
+    def test_read_constrained_too_tight(self):
+        sfg = ldlt_matrix_inverse(N=2)
+
+        sfg.set_latency_of_type(MADS.type_name(), 3)
+        sfg.set_latency_of_type(Reciprocal.type_name(), 2)
+        sfg.set_execution_time_of_type(MADS.type_name(), 1)
+        sfg.set_execution_time_of_type(Reciprocal.type_name(), 1)
+
+        resources = {MADS.type_name(): 1, Reciprocal.type_name(): 1}
+        with pytest.raises(
+            TimeoutError,
+            match="Algorithm did not schedule any operation for 10 time steps, try relaxing constraints.",
+        ):
+            Schedule(
+                sfg,
+                scheduler=HybridScheduler(
+                    max_resources=resources,
+                    max_concurrent_reads=2,
+                ),
+            )
diff --git a/test/test_graph_id_generator.py b/test/unit/test_graph_id_generator.py
similarity index 100%
rename from test/test_graph_id_generator.py
rename to test/unit/test_graph_id_generator.py
diff --git a/test/test_gui.py b/test/unit/test_gui.py
similarity index 100%
rename from test/test_gui.py
rename to test/unit/test_gui.py
diff --git a/test/test_gui/twotapfir.py b/test/unit/test_gui/twotapfir.py
similarity index 100%
rename from test/test_gui/twotapfir.py
rename to test/unit/test_gui/twotapfir.py
diff --git a/test/test_inputport.py b/test/unit/test_inputport.py
similarity index 100%
rename from test/test_inputport.py
rename to test/unit/test_inputport.py
diff --git a/test/test_operation.py b/test/unit/test_operation.py
similarity index 100%
rename from test/test_operation.py
rename to test/unit/test_operation.py
diff --git a/test/test_outputport.py b/test/unit/test_outputport.py
similarity index 100%
rename from test/test_outputport.py
rename to test/unit/test_outputport.py
diff --git a/test/test_process.py b/test/unit/test_process.py
similarity index 100%
rename from test/test_process.py
rename to test/unit/test_process.py
diff --git a/test/test_quantization.py b/test/unit/test_quantization.py
similarity index 100%
rename from test/test_quantization.py
rename to test/unit/test_quantization.py
diff --git a/test/test_resources.py b/test/unit/test_resources.py
similarity index 100%
rename from test/test_resources.py
rename to test/unit/test_resources.py
diff --git a/test/test_schedule.py b/test/unit/test_schedule.py
similarity index 100%
rename from test/test_schedule.py
rename to test/unit/test_schedule.py
diff --git a/test/test_scheduler_gui.py b/test/unit/test_scheduler_gui.py
similarity index 100%
rename from test/test_scheduler_gui.py
rename to test/unit/test_scheduler_gui.py
diff --git a/test/test_sfg.py b/test/unit/test_sfg.py
similarity index 100%
rename from test/test_sfg.py
rename to test/unit/test_sfg.py
diff --git a/test/test_sfg_generators.py b/test/unit/test_sfg_generators.py
similarity index 100%
rename from test/test_sfg_generators.py
rename to test/unit/test_sfg_generators.py
diff --git a/test/test_signal.py b/test/unit/test_signal.py
similarity index 100%
rename from test/test_signal.py
rename to test/unit/test_signal.py
diff --git a/test/test_signal_generator.py b/test/unit/test_signal_generator.py
similarity index 100%
rename from test/test_signal_generator.py
rename to test/unit/test_signal_generator.py
diff --git a/test/test_signal_generator/bad.csv b/test/unit/test_signal_generator/bad.csv
similarity index 100%
rename from test/test_signal_generator/bad.csv
rename to test/unit/test_signal_generator/bad.csv
diff --git a/test/test_signal_generator/input.csv b/test/unit/test_signal_generator/input.csv
similarity index 66%
rename from test/test_signal_generator/input.csv
rename to test/unit/test_signal_generator/input.csv
index b0917c8e..64737476 100644
--- a/test/test_signal_generator/input.csv
+++ b/test/unit/test_signal_generator/input.csv
@@ -1,5 +1,5 @@
-0
-1
-0
-0
-0
+0
+1
+0
+0
+0
diff --git a/test/test_simulation.py b/test/unit/test_simulation.py
similarity index 100%
rename from test/test_simulation.py
rename to test/unit/test_simulation.py
diff --git a/test/test_simulation_gui.py b/test/unit/test_simulation_gui.py
similarity index 100%
rename from test/test_simulation_gui.py
rename to test/unit/test_simulation_gui.py
diff --git a/test/test_utils.py b/test/unit/test_utils.py
similarity index 100%
rename from test/test_utils.py
rename to test/unit/test_utils.py
-- 
GitLab