diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index fa96158..d5abac8 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -19,7 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e . + python -m pip install -e ".[dev]" - name: Build and test docs run: | if [ ${{ github.ref }} == "refs/heads/main" ]; then diff --git a/.github/workflows/test_package.yml b/.github/workflows/test_package.yml index 4e8277f..c853454 100644 --- a/.github/workflows/test_package.yml +++ b/.github/workflows/test_package.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v3 @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e . + python -m pip install -e ".[dev]" - name: Test with pytest run: | python -m pytest diff --git a/README.md b/README.md index 45c59c7..ce8dd8f 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ class SimpleLine(Line): # Wire them with buffers source.connect_to_output(station=process, capacity=3) - process.connect_to_output(station=process, capacity=2) + process.connect_to_output(station=sink, capacity=2) line = SimpleLine() diff --git a/VERSION b/VERSION index 4e379d2..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.2 +0.1.0 diff --git a/docs/userguide/core_concepts.md b/docs/userguide/core_concepts.md index 04bcb45..5812e2d 100644 --- a/docs/userguide/core_concepts.md +++ b/docs/userguide/core_concepts.md @@ -205,76 +205,51 @@ updated, this is directly visible in the state of the line. ## Parts and Carriers +In `LineFlow`, every part is transported by a carrier as it +moves through the production line. Hence, carriers act as mobile containers: once a +[`Source`][lineflow.simulation.stations.Source] (or a +[`Magazine`][lineflow.simulation.stations.Magazine]) creates a new carrier, it puts a predefined +sequence of initial parts onto the newly created carrier. +At each station the carrier visits, new parts can be added or existing parts removed, typically at +[`Assembly`][lineflow.simulation.stations.Assembly] stations. + +Parts can show different behaviors at different stations. For instance, a certain part may require +an additional processing time at a given process or has to statisfy a certain condition before it +can be assembled. These specification are fixed when creating the part at the source and can be +given in a `carrier_spec`. For instance, the following parameter can be given when creating a source +station: -### Carriers -In LineFlow, every individual Part is always transported by a Carrier as it -moves through the production line. Carriers act as mobile containers: once a -Source station creates a new Part (or set of Parts), it places them onto a -Carrier, which then follows the sequence of Buffers and Stations. At each step, -the Carrier carries its load of Parts downstream—whether through processing, -assembly, or inspection—until it reaches a Sink. Because Parts never traverse -the line on their own, all material flow, blocking behavior, and routing logic -hinge on Carrier movement and occupancy. - -### Parts - -1. **Define Part Specifications**: - - Part specifications are defined as a list of dictionaries, where each dictionary contains the attributes of a part. For example: - ```python - part_specs = [ - {"attribute1": value1, "attribute2": value2}, - {"attribute1": value3, "attribute2": value4}, - ] - ``` - -2. **Initialize the Source Station**: - - When initializing a [`Source`][lineflow.simulation.stations.Source] - station, pass the part specs list to the part_specs parameter: - ```python - source = Source( - name="source_name", - part_specs=part_specs, - # other parameters - ) - ``` - -3. **Create Parts**: - - The [`create_parts`][lineflow.simulation.stations.Source.create_parts] method of the - [`Source`][lineflow.simulation.stations.Source] class is responsible for - creating parts based on the part_specs attribute. This method iterates - over each dictionary in the part_specs list and creates a [`Part`][lineflow.simulation.movable_objects.Part] object - for each specification: - ```python - def create_parts(self): - parts = [] - for part_spec in self.part_specs: - part = Part( - env=self.env, - name=self.name + '_part_' + str(self.part_id), - specs=part_spec, - ) - self.part_id += 1 - part.create(self.position) - parts.append(part) - return parts - ``` - -4. **Assemble Parts on Carrier**: - - Once the parts are created, they can be assembled onto a carrier using the - [`assemble_parts_on_carrier`][lineflow.simulation.stations.Source.assemble_parts_on_carrier] - method: - ```python - def assemble_parts_on_carrier(self, carrier, parts): - for part in parts: - carrier.assemble(part) - ``` - -5. **Derive Actions from Part Specifications**: - - Actions can be derived from the new state of the parts. The [`apply`][lineflow.simulation.stations.Station] method in the [`Station`][lineflow.simulation.stations.Station] class can be used to apply these actions: - ```python - def apply(self, actions): - self._derive_actions_from_new_state(actions) - self.state.apply(actions) - ``` - -By following these steps, you can create new parts with specific attributes, initialize them in a source station, and derive actions based on their specifications. +```python +carrier_spec = { + "CarrierA": { + "Part1": { + "P1": {"extra_processing_time": 5}, + "A1": {"assembly_condition": 10}, + } + "Part2": { + "P1": {"extra_processing_time": 1}, + "P2": {"extra_processing_time": 3}, + "A1": {"assembly_condition": 8}, + } + }, + "CarrierB": { + "Part1": { + "A1": {"assembly_condition": 10}, + } + "Part2": { + "P2": {"extra_processing_time": 3}, + } + "Part3": { + "P1": {"extra_processing_time": 1}, + "P2": {"extra_processing_time": 3}, + "A1": {"assembly_condition": 8}, + } + } +} +``` + +Here, the source creates (randomly) either a carrier of type `CarrierA` or of `CarrierB`. `CarrierA` +has two parts, `Part1` and `Part2`, each with their own specifications. For instance, `Part1` +consues an additional processing time of 5 time stepts at station `P1` and needs to be assembled at +station `A1` within `10` time steps from its creation. Similarly, `Part2` has a processing time of 1 +at `P1`, 3 at `P2`, and an assembly condition of 8 at `A1`. diff --git a/docs/userguide/quickstart.md b/docs/userguide/quickstart.md index e3764dd..672b00a 100644 --- a/docs/userguide/quickstart.md +++ b/docs/userguide/quickstart.md @@ -15,6 +15,7 @@ class SimpleLine(Line): Source( name='Source', processing_time=5, + unlimited_carriers=True, buffer_out=buffer, position=(100, 300), ) @@ -30,9 +31,9 @@ from lineflow.simulation import Line, Source, Sink class SimpleLine(Line): def build(self): - source = Source(name='Source', processing_time=5, position=(100, 300)) + source = Source(name='Source', processing_time=5, position=(100, 300), unlimited_carriers=True) sink = Sink('Sink', position=(600, 300)) - sink.connect_to_output(station=source, capacity=6) + source.connect_to_output(station=sink, capacity=6) ``` diff --git a/lineflow/examples/__init__.py b/lineflow/examples/__init__.py index 7dac446..93f3904 100644 --- a/lineflow/examples/__init__.py +++ b/lineflow/examples/__init__.py @@ -6,4 +6,5 @@ from lineflow.examples.worker_assignment import WorkerAssignment #noqa from lineflow.examples.double_source import DoubleSource #noqa from lineflow.examples.waiting_time import WaitingTime #noqa -from lineflow.examples.complex_line import ComplexLine #noqa \ No newline at end of file +from lineflow.examples.complex_line import ComplexLine #noqa +from lineflow.examples.part_dependence import PartDependentProcessLine #noqa diff --git a/lineflow/examples/complex_line.py b/lineflow/examples/complex_line.py index efb5756..6b2cfe8 100644 --- a/lineflow/examples/complex_line.py +++ b/lineflow/examples/complex_line.py @@ -198,9 +198,13 @@ def build(self): unlimited_carriers=True, carrier_capacity=1, actionable_waiting_time=True, - part_specs=[{ - "assembly_condition": self.assembly_condition, - }], + carrier_specs={ + 'Carrier': { + 'Part': { + f'A{i}': {"assembly_condition": self.assembly_condition} for i in range(self.n_assemblies) + } + } + } ) switch = Switch( diff --git a/lineflow/examples/component_assembly.py b/lineflow/examples/component_assembly.py index 96aec74..089dee6 100644 --- a/lineflow/examples/component_assembly.py +++ b/lineflow/examples/component_assembly.py @@ -29,15 +29,18 @@ def build(self): 'A1', processing_time=4, position=(500, 300), - part_specs=[{ - "assembly_condition": 40 - }], + carrier_specs={ + 'A': { + 'ComponentA': { + 'C21': {"assembly_condition": 40}, + 'C22': {"assembly_condition": 40}, + } + } + }, unlimited_carriers=True, carrier_capacity=1, ) - - switch1 = Switch('S1', alternate=True, position=(200, 300),) switch2 = Switch('S2', alternate=True, position=(300, 300)) switch3 = Switch('S3', alternate=True, position=(580, 300)) @@ -95,9 +98,13 @@ def build(self): 'A2', processing_time=5, position=(600, 400), - part_specs=[{ - "assembly_condition": 400 - }], + carrier_specs={ + 'B': { + 'Component_B': { + 'C5': {"assembly_condition": 400}, + } + } + }, unlimited_carriers=True, carrier_capacity=1, ) diff --git a/lineflow/examples/part_dependence.py b/lineflow/examples/part_dependence.py new file mode 100644 index 0000000..329c8ec --- /dev/null +++ b/lineflow/examples/part_dependence.py @@ -0,0 +1,48 @@ +from lineflow.simulation import ( + WorkerPool, + Source, + Sink, + Line, + Process, +) + + +class PartDependentProcessLine(Line): + def build(self): + source = Source( + name='Source', + processing_time=10, + position=(100, 200), + unlimited_carriers=True, + carrier_capacity=1, + carrier_min_creation=10, + carrier_specs={ + 'Type_A': { + 'Part1': { + 'P1': {'extra_processing_time': 20}, + 'P2': {'extra_processing_time': 0} + } + }, + 'Type_B': { + 'Part1': { + 'P1': {'extra_processing_time': 0}, + 'P2': {'extra_processing_time': 20} + } + }, + }, + ) + + pool = WorkerPool(name='Pool', n_workers=4) + + p1 = Process('P1', processing_time=10, position=(350, 200), worker_pool=pool) + p2 = Process('P2', processing_time=10, position=(700, 200), worker_pool=pool) + sink = Sink('Sink', position=(850, 200)) + + p1.connect_to_input(source, capacity=15) + p2.connect_to_input(p1, capacity=15) + sink.connect_to_input(p2) + + +if __name__ == '__main__': + line = PartDependentProcessLine(realtime=True, factor=0.8) + line.run(simulation_end=1000, visualize=True) diff --git a/lineflow/examples/part_dependent_process.py b/lineflow/examples/part_dependent_process.py deleted file mode 100644 index c0f2fec..0000000 --- a/lineflow/examples/part_dependent_process.py +++ /dev/null @@ -1,143 +0,0 @@ -from lineflow.simulation import ( - Buffer, - Source, - Sink, - Line, - Process, -) - -from lineflow.simulation.movable_objects import Part - - -class AlternatingSource(Source): - """ - Alternating Source that takes as an argument a list of part specs and - creates parts with these specs in a random order. - This station assumes only one part per carrier. - - Args: - part_specs (list): List of dictoionaries with part specs - """ - def __init__(self, name, random_state, part_specs, *args, **kwargs): - super().__init__(name=name, *args, **kwargs) - self.part_specs = part_specs - self.random = random_state - - def create_parts(self): - parts = [] - - # choose a random dictionary out of self.part_specs - spec = self.random.choice(self.part_specs) - part = Part( - env=self.env, - name=self.name + '_part_' + str(self.part_id), - specs=spec, - ) - self.part_id += 1 - part.create(self.position) - parts.append(part) - return parts - - -class PartDependentProcess(Process): - ''' - Process stations take a carrier as input, process the carrier, and push it onto buffer_out - Args: - processing_std: Standard deviation of the processing time - rework_probability: Probability of a carrier to be reworked (takes 2x the time) - position (tuple): X and Y position in visualization - ''' - - def __init__( - self, - name, - buffer_in=None, - buffer_out=None, - position=None, - processing_std=None, - rework_probability=0, - worker_pool=None, - ): - super().__init__( - name=name, - position=position, - processing_std=processing_std, - rework_probability=rework_probability, - worker_pool=worker_pool, - processing_time=0, - buffer_in=buffer_in, - buffer_out=buffer_out, - ) - - def run(self): - - while True: - if self.is_on(): - yield self.env.process(self.request_workers()) - self.state['n_workers'].update(self.n_workers) - # Wait to get part from buffer_in - yield self.env.process(self.set_to_waiting()) - carrier = yield self.env.process(self.buffer_in()) - self.state['carrier'].update(carrier.name) - - yield self.env.process(self.set_to_work()) - - # assumes only one part - part = next(iter(carrier.parts.values())) - part_dependent_processing_time = part.specs['processing_time'] - - processing_time = self._sample_exp_time( - time=part_dependent_processing_time, - scale=self.processing_std, - rework_probability=self.rework_probability, - ) - yield self.env.timeout(processing_time) - self.state['processing_time'].update(processing_time) - - # Release workers - self.release_workers() - - # Wait to place carrier to buffer_out - yield self.env.process(self.set_to_waiting()) - yield self.env.process(self.buffer_out(carrier)) - self.state['carrier'].update(None) - - else: - yield self.turn_off() - - -class PartDependentProcessLine(Line): - def build(self): - # Configure a simple line - buffer_2 = Buffer('Buffer2', capacity=5, transition_time=5) - buffer_3 = Buffer('Buffer3', capacity=3, transition_time=3) - - AlternatingSource( - name='Source', - processing_time=5, - buffer_out=buffer_2, - position=(100, 500), - waiting_time=10, - unlimited_carriers=True, - carrier_capacity=1, - part_specs=[{'processing_time': 10}, {'processing_time': 20}], - random_state=self.random, # Use random state of mother class - ) - - PartDependentProcess( - 'Process', - buffer_in=buffer_2, - buffer_out=buffer_3, - position=(350, 500), - ) - - Sink( - 'Sink', - buffer_in=buffer_3, - position=(600, 500), - ) - - -if __name__ == '__main__': - line = PartDependentProcessLine() - line.run(simulation_end=1000, visualize=True, capture_screen=True) diff --git a/lineflow/examples/showcase_line.py b/lineflow/examples/showcase_line.py index 996d558..a93057d 100644 --- a/lineflow/examples/showcase_line.py +++ b/lineflow/examples/showcase_line.py @@ -49,9 +49,7 @@ def build(self): processing_time=5, waiting_time=0, carrier_capacity=1, - part_specs=[{ - "assembly_condition": 100 - }], + carrier_specs={'Carrier': {'Part': {'Assembly': {"assembly_condition": 100}}}}, unlimited_carriers=True, actionable_waiting_time=True, ) diff --git a/lineflow/examples/waiting_time.py b/lineflow/examples/waiting_time.py index 90f7844..b61dc02 100644 --- a/lineflow/examples/waiting_time.py +++ b/lineflow/examples/waiting_time.py @@ -150,9 +150,9 @@ def build(self): waiting_time=0, waiting_time_step=1, carrier_capacity=1, - part_specs=[{ - "assembly_condition": self.assembly_condition - }], + carrier_specs={ + 'carrier': {"Part": {"Assembly": {"assembly_condition": self.assembly_condition}}} + }, unlimited_carriers=True, actionable_waiting_time=True, ) diff --git a/lineflow/simulation/line.py b/lineflow/simulation/line.py index a3c83c6..d5ba5cd 100644 --- a/lineflow/simulation/line.py +++ b/lineflow/simulation/line.py @@ -89,6 +89,21 @@ def _make_objects(self): raise ValueError(f'Multiple objects with name {obj.name} exist') self._objects[obj.name] = obj + # Validate carrier specs + for obj in self._objects.values(): + if hasattr(obj, 'carrier_specs'): + self._validate_carrier_specs(obj.carrier_specs) + + def _validate_carrier_specs(self, specs): + for carrier_name, part_specs in specs.items(): + for part_name, part_spec in part_specs.items(): + for station in part_spec.keys(): + if station not in self._objects: + raise ValueError( + f"Spec for part '{part_name}' in carrier '{carrier_name}' " + f"contains unkown station '{station}'" + ) + def _build_states(self): """ Builds the states of the line objects as well as the LineState diff --git a/lineflow/simulation/movable_objects.py b/lineflow/simulation/movable_objects.py index e35df07..a5ca432 100644 --- a/lineflow/simulation/movable_objects.py +++ b/lineflow/simulation/movable_objects.py @@ -118,14 +118,14 @@ def __init__(self, env, name, specs=None, color='Orange'): super(Part, self).__init__(env, name, specs=specs) self._color = color - def is_valid_for_assembly(self): + def is_valid_for_assembly(self, station_name): """ If the part has an `assembly_condition` in its specification, then it checks whether the time between its creation and now is smaller than this condition. Otherwise it will just return true. """ - if "assembly_condition" in self.specs: - return (self.env.now - self["creation_time"]) < self["assembly_condition"] + if "assembly_condition" in self.specs.get(station_name, {}): + return (self.env.now - self["creation_time"]) < self.specs[station_name]["assembly_condition"] else: return True @@ -141,13 +141,17 @@ def _draw(self, screen, x, y, width, height): class Carrier(MovableObject): - def __init__(self, env, name, color='Black', width=30, height=10, capacity=np.inf): + def __init__(self, env, name, color='Black', width=30, height=10, capacity=np.inf, part_specs=None): super(Carrier, self).__init__(env, name, specs=None) self.capacity = capacity self._color = color self._width = width self._height = height + if part_specs is None: + part_specs = {} + self.part_specs = part_specs.copy() + self._width_part = 0.8*self._width if capacity < 15: self._width_part = self._width_part / self.capacity @@ -163,6 +167,10 @@ def assemble(self, part): if not hasattr(part, "creation_time"): raise ValueError('Part not created') + + if self.capacity == len(self.parts): + raise ValueError('Carrier is already full. Check your carrier_capacity') + self.parts[part.name] = part def _draw_shape(self, screen): @@ -203,3 +211,15 @@ def move(self, position): def __iter__(self): for part in self.parts.values(): yield part + + def get_additional_processing_time(self, station): + total_time = 0 + + for part in self: + processing_time = part.specs.get(station, {}).get("extra_processing_time", 0) + total_time += processing_time + + return total_time + + + diff --git a/lineflow/simulation/stations.py b/lineflow/simulation/stations.py index 74a538b..efb8e86 100644 --- a/lineflow/simulation/stations.py +++ b/lineflow/simulation/stations.py @@ -408,7 +408,7 @@ def _has_invalid_components_on_carrier(self, carrier): `True` is returned. Otherwise, `False` is returned. """ for component in carrier: - if not component.is_valid_for_assembly(): + if not component.is_valid_for_assembly(self.name): return True return False @@ -456,7 +456,7 @@ def run(self): # Process components yield self.env.process(self.set_to_work()) processing_time = self._sample_exp_time( - time=self.processing_time, + time=self.processing_time + carrier.get_additional_processing_time(self.name), scale=self.processing_std, ) yield self.env.timeout(processing_time) @@ -552,7 +552,7 @@ def run(self): yield self.env.process(self.set_to_work()) processing_time = self._sample_exp_time( - time=self.processing_time, + time=self.processing_time + carrier.get_additional_processing_time(self.name), scale=self.processing_std, rework_probability=self.rework_probability, ) @@ -581,9 +581,12 @@ class Source(Station): Args: name (str): Name of the Cell - part_specs (list): List of dictionaries. Each dict contain specification about a part that - is assembled on the carrier i.e.: [{"assembly_condition": 5}]. Assembly condition - defines the maxmim time a part can be on the line before not being able to be assembled. + carrier_specs (dict): Nested dict. Top level descripes carrier types, each consists of a + dict specifying different parts setup on the carrier at the source. The part level + specifies how the part behaves at future processes along the layout. For instance a spec + `{'C': {'Part1': {'Process1': {'assembly_condition': 5}, 'Process2': {'extra_processing_time': 10}}}}` + tells that the produced carrier has one part `Part1` that has to fullfill an assembly condition of `5` + at station `Process1` and gets an additional processing time of `10` at `Process2`. buffer_in (lineflow.simulation.connectors.Buffer, optional): Buffer in buffer_out (obj): Buffer out processing_time (float): Time it takes to put part on carrier (carrier needs to be @@ -594,12 +597,13 @@ class Source(Station): unlimited_carriers (bool): If source has the ability to create unlimited carriers carrier_capacity (int): Defines how many parts can be assembled on a carrier. If set to default (infinity) or > 15, carrier will be visualized with one part. + carrier_min_creation (int): Minimum number of carriers of same spec created subsequentially + carrier_max_creation (int): Maximum number of carriers of same spec created subsequentially ''' def __init__( self, name, - part_specs=None, buffer_in=None, buffer_out=None, processing_time=2, @@ -611,6 +615,9 @@ def __init__( actionable_waiting_time=True, unlimited_carriers=False, carrier_capacity=np.inf, + carrier_specs=None, + carrier_min_creation=1, + carrier_max_creation=None, ): super().__init__( name=name, @@ -633,15 +640,17 @@ def __init__( self.actionable_magazin = actionable_magazin self.actionable_waiting_time = actionable_waiting_time - if part_specs is None: - part_specs = [None] - - self.part_specs = part_specs - self.part_id = 1 + if carrier_specs is None: + carrier_specs = {"carrier": {"part": {}}} + self.carrier_specs = carrier_specs self.unlimited_carriers = unlimited_carriers self.carrier_capacity = carrier_capacity self.carrier_id = 1 + self.carrier_min_creation = carrier_min_creation + self.carrier_max_creation = carrier_max_creation if carrier_max_creation is not None else 2*carrier_min_creation + + self._carrier_counter = 0 self.init_waiting_time = waiting_time @@ -650,9 +659,19 @@ def init_state(self): self.state = ObjectStates( DiscreteState('on', categories=[True, False], is_actionable=False, is_observable=False), DiscreteState('mode', categories=['working', 'waiting', 'failing']), - DiscreteState('waiting_time', categories=np.arange(0, 100, self.waiting_time_step), is_actionable=self.actionable_waiting_time), + DiscreteState( + name='waiting_time', + categories=np.arange(0, 100, self.waiting_time_step), + is_actionable=self.actionable_waiting_time, + ), TokenState(name='carrier', is_observable=False), TokenState(name='part', is_observable=False), + DiscreteState( + name='carrier_spec', + categories=list(self.carrier_specs.keys()), + is_actionable=False, + is_observable=True, + ), ) self.state['waiting_time'].update(self.init_waiting_time) @@ -660,6 +679,7 @@ def init_state(self): self.state['mode'].update("waiting") self.state['carrier'].update(None) self.state['part'].update(None) + self.state['carrier_spec'].update(list(self.carrier_specs.keys())[0]) def _assert_init_args(self, unlimited_carriers, carrier_capacity, buffer_in): if unlimited_carriers: @@ -671,24 +691,42 @@ def _assert_init_args(self, unlimited_carriers, carrier_capacity, buffer_in): raise AttributeError("Type of carrier capacity must be int or np.inf") def create_carrier(self): - name = f'{self.name}_cr_{self.carrier_id}' - carrier = Carrier(self.env, name=name, capacity=self.carrier_capacity) + + if self._carrier_counter == 0: + carrier_spec = self.random.choice(list(self.carrier_specs.keys())) + self.state['carrier_spec'].update(carrier_spec) + self._carrier_counter = self.random.randint( + self.carrier_min_creation, + self.carrier_max_creation + 1, + ) + + carrier_spec = self.state['carrier_spec'].to_str() + + name = f'{self.name}_{carrier_spec}_{self.carrier_id}' + carrier = Carrier( + self.env, + name=name, + capacity=self.carrier_capacity, + part_specs=self.carrier_specs[carrier_spec], + ) self.carrier_id += 1 + self._carrier_counter -= 1 + return carrier - def create_parts(self): + def create_parts(self, carrier): """ Creates the parts based on the part_specs attribute For each dict in the part_specs list one part is created """ + parts = [] - for part_spec in self.part_specs: + for part_id, (part_name, part_spec) in enumerate(carrier.part_specs.items()): part = Part( env=self.env, - name=self.name + '_part_' + str(self.part_id), + name=f"{carrier.name}_{part_name}_{part_id}", specs=part_spec, ) - self.part_id += 1 part.create(self.position) parts.append(part) return parts @@ -702,7 +740,7 @@ def assemble_parts_on_carrier(self, carrier, parts): def assemble_carrier(self, carrier): - parts = self.create_parts() + parts = self.create_parts(carrier) self.state['part'].update(parts[0].name) processing_time = self._sample_exp_time( @@ -1018,6 +1056,9 @@ def __init__( actionable_magazine=True, carrier_getting_time=2, carriers_in_magazine=0, + carrier_specs=None, + carrier_min_creation=1, + carrier_max_creation=None, ): super().__init__( name=name, @@ -1033,10 +1074,17 @@ def __init__( self.actionable_magazine = actionable_magazine self.init_carriers_in_magazine = carriers_in_magazine self.carrier_getting_time = carrier_getting_time + + if carrier_specs is None: + carrier_specs = {"carrier": {"part": {}}} + self.carrier_specs = carrier_specs + self.unlimited_carriers = unlimited_carriers self.carrier_capacity = carrier_capacity - self.carrier_id = 1 + self.carrier_min_creation = carrier_min_creation + self.carrier_max_creation = carrier_max_creation if carrier_max_creation is not None else 2*carrier_min_creation + self._carrier_counter = 0 def init_state(self): @@ -1071,9 +1119,23 @@ def _assert_init_args(self, buffer_in, unlimited_carriers, carriers_in_magazine, raise AttributeError(f"Only magazine or unlimited_carriers {unlimited_carriers} is required") def create_carrier(self): - name = f'{self.name}_cr_{self.carrier_id}' - carrier = Carrier(self.env, name=name, capacity=self.carrier_capacity) + if self._carrier_counter == 0: + self._current_carrier_spec = self.random.choice(list(self.carrier_specs.keys())) + self._carrier_counter = self.random.randint( + self.carrier_min_creation, + self.carrier_max_creation + 1, + ) + + name = f'{self.name}_{self._current_carrier_spec}_{self.carrier_id}' + carrier = Carrier( + self.env, + name=name, + capacity=self.carrier_capacity, + part_specs=self.carrier_specs[self._current_carrier_spec], + ) self.carrier_id += 1 + self._carrier_counter -= 1 + return carrier def _initial_fill_magazine(self, n_carriers): diff --git a/pyproject.toml b/pyproject.toml index 18d55a5..b6fa850 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ maintainers = [ description = 'Python tool to simulate assembly lines.' readme = "README.md" dynamic = ['version'] -requires-python = "<3.13" +requires-python = "<3.14" dependencies = [ 'pandas', 'scipy', @@ -24,13 +24,6 @@ dependencies = [ 'numpy<2.0.0', 'matplotlib', 'pygame', - 'mkdocs', - 'mkdocs-material', - 'mkdocstrings-python', - 'mike', - 'ruff', - 'pytest', - 'pyarrow', # ? 'torch', 'wandb', 'tqdm', @@ -41,7 +34,16 @@ dependencies = [ [project.optional-dependencies] -dev = [] +dev = [ + 'pytest', + 'pytest-cov', + 'mkdocs', + 'mkdocs-material', + 'mkdocstrings-python', + 'mike', + 'ruff', + 'debugpy', +] [tool.setuptools.packages.find] include = ['lineflow*'] diff --git a/tests/test_assembly.py b/tests/test_assembly.py index f284a89..4d808dd 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -34,9 +34,9 @@ def build(self): processing_std=0, buffer_out=buffer_3, position=(300, 400), - part_specs=[{ - "assembly_condition": 10 - }], + carrier_specs={ + 'A': {'Part': {'Assembly': {"assembly_condition": 10}}} + }, unlimited_carriers=True, carrier_capacity=1, ) @@ -99,9 +99,9 @@ def build(self): buffer_in=buffer_5, buffer_out=buffer_3, position=(300, 400), - part_specs=[{ - "assembly_condition": 14 - }], + carrier_specs={ + 'A': {'Part': {'Assembly': {"assembly_condition": 14}}} + }, ) Assembly( @@ -163,9 +163,9 @@ def build(self): buffer_out=buffer_3, buffer_in=buffer_5, position=(300, 400), - part_specs=[{ - "assembly_condition": 5 - }], + carrier_specs={ + 'A': {'Part': {'Assembly': {"assembly_condition": 5}}} + }, ) Assembly( @@ -222,8 +222,8 @@ def test_transition_time(self): # 5 (processing) + 1 (put) + 1 (get next one) self.assertEqual( self.df_valid[ - (self.df_valid['carrier'] == 'Magazine Source_cr_2') & - (self.df_valid['carrier_component'] == 'Magazine Component Source_cr_2') + (self.df_valid['carrier'] == 'Magazine Source_carrier_2') & + (self.df_valid['carrier_component'] == 'Magazine Component Source_carrier_2') ].iloc[0]["T_start"], 26 ) @@ -231,6 +231,7 @@ def test_transition_time(self): def test_assembly_condition(self): # Should only have one carrier + None self.assertEqual(len(self.df["carrier"].unique()), 2) + # Should have multiple carrier_components self.assertGreater(len(self.df["carrier_component"].unique()), 2) diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 2ed2576..9c50040 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -95,7 +95,7 @@ def test_with_fast_removal(self): line.run(60) df = line.get_observations('Sink') - index = df[df['carrier'] == 'Magazine_cr_1'].index + index = df[df['carrier'] == 'Magazine_carrier_1'].index t_end = df.loc[index[0] + 1, 'T_end'] # First part should be removed after # 1 (getting time) + 1 (put) + 15 (transition)+ 1 (put) + 1 (process) + 1 (put) + 15 (transition) + 1 (get) + 1 (remove) @@ -104,7 +104,7 @@ def test_with_fast_removal(self): t_end, 1 + 1 + 15 + 1 + 1 + 1 + 15 + 1 + 1) - index = df[df['carrier'] == 'Magazine_cr_2'].index + index = df[df['carrier'] == 'Magazine_carrier_2'].index t_end = df.loc[index[0] + 1, 'T_end'] # Second part should be removed after # 37 (time first carrier needs with removal) + 15 / 3 (time second diff --git a/tests/test_examples.py b/tests/test_examples.py index 0eeaed2..c09611b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -10,6 +10,7 @@ WaitingTime, MultiSink, ComplexLine, + PartDependentProcessLine, ) from lineflow.examples.complex_line import make_agent @@ -79,9 +80,11 @@ def test_multi_sink(self): line = MultiSink() line.run(simulation_end=400) - def test_complex_line(self): - + def test_part_dependent_process_line(self): + line = PartDependentProcessLine() + line.run(simulation_end=400) + def test_complex_line(self): line = ComplexLine(n_workers=15, alternate=False, n_assemblies=5) agent = make_agent( state=line.state, diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 72620bd..3df4dd3 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -26,7 +26,7 @@ def build(self): mc1 = Magazine('MC1', unlimited_carriers=True) ma1 = Magazine('MA1', unlimited_carriers=True) - a1 = Source('A1', part_specs=[{"assembly_condition": 20}]) + a1 = Source('A1', carrier_specs={'A': {'Part': {'C2': {"assembly_condition": 20}}}}) c1 = Source('C1') diff --git a/tests/test_line.py b/tests/test_line.py index 4635f3c..c622290 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -24,11 +24,17 @@ def __init__(self, std=None, *args, **kwargs): def build(self): - m1 = Magazine('M1', unlimited_carriers=True) + m1 = Magazine( + name='M1', + unlimited_carriers=True, + carrier_specs={ + 'A': {'Part': {'C2': {"assembly_condition": 20}}} + } + ) m2 = Magazine('M2', unlimited_carriers=True) - a1 = Source('A1', part_specs=[{"assembly_condition": 20}]) + a1 = Source('A1') c1 = Source('C1') @@ -71,6 +77,19 @@ def test_element_access(self): self.assertIsInstance(self.line['Buffer_C1_to_C2'], Buffer) self.assertIsInstance(self.line['C2'], Assembly) + def test_valid_carrier_specs(self): + self.line.run(simulation_end=15) + + with self.assertRaises(ValueError): + self.line._validate_carrier_specs( + {'carrier_name': { + 'Part': { + 'C2': {"assembly_condition": 20}, + 'M2': {"assembly_condition": 20}, + 'C33': {"assembly_condition": 20} + } + }}) + def test_processing_times_with_randomization(self): line = LineWithAssembly(std=0.9) diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 89764fd..4e7b343 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -195,17 +195,60 @@ def policy(line_state, env): self.removal_line.run(simulation_end=50, agent=policy) df = self.removal_line.get_observations("Magazine") # Magazine produces carrier 'Magazine_cr_1' - self.assertEqual(df.iloc[2].carrier, 'Magazine_cr_1') + self.assertEqual(df.iloc[2].carrier, 'Magazine_carrier_1') # Afterwards all carriers are removed exept one (the tenth) - self.assertEqual(df.iloc[5].carrier, 'Magazine_cr_10') + self.assertEqual(df.iloc[5].carrier, 'Magazine_carrier_10') - def test_part_specs(self): + def test_carrier_specs(self): source = Source( name="TestSource", - part_specs=[ - {"assembly_condition": 8} - ] + carrier_specs={ + 'Carrier1': { + 'Part1': { + 'C1': {"assembly_condition": 11}, + 'C2': {"assembly_condition": 12}, + }, + 'Part2': { + 'C1': {"assembly_condition": 11}, + 'C3': {"assembly_condition": 13}, + }, + }, + 'Carrier2': { + 'Part1': { + 'C1': {"assembly_condition": 21}, + 'C2': {"assembly_condition": 22}, + }, + 'Part2': { + 'C1': {"assembly_condition": 21}, + 'C3': {"assembly_condition": 23}, + }, + } + } ) + source.init(np.random.RandomState(0)) source.env = simpy.Environment() - parts = source.create_parts() - self.assertTrue(parts[0]["assembly_condition"] == 8) + carrier = source.create_carrier() + + self.assertEqual(carrier.name, 'TestSource_Carrier1_1') + + parts = source.create_parts(carrier) + self.assertEqual(len(parts), 2) + + self.assertDictEqual( + parts[0].specs['C1'], + {"assembly_condition": 11}, + ) + self.assertDictEqual( + parts[0].specs['C2'], + {"assembly_condition": 12}, + ) + + + self.assertDictEqual( + parts[1].specs['C1'], + {"assembly_condition": 11}, + ) + self.assertDictEqual( + parts[1].specs['C3'], + {"assembly_condition": 13}, + ) diff --git a/tests/test_switches.py b/tests/test_switches.py index 77d6ced..8c37094 100644 --- a/tests/test_switches.py +++ b/tests/test_switches.py @@ -104,7 +104,7 @@ def test_without_alternate(self): # All parts should visit P1, none vists P2 and P3 p1, p2, p3 = self.get_parts_at_processes(line) - self.assertListEqual(p1, [f'M1_cr_{i}' for i in range(1, 7)]) + self.assertListEqual(p1, [f'M1_carrier_{i}' for i in range(1, 7)]) self.assertListEqual(p2, []) self.assertListEqual(p3, []) @@ -135,9 +135,18 @@ def test_alternate(self): p1, p2, p3 = self.get_parts_at_processes(line) - self.assertListEqual(p1, ['M1_cr_1', 'M2_cr_2', 'M1_cr_4', 'M2_cr_5']) - self.assertListEqual(p2, ['M2_cr_1', 'M1_cr_3', 'M2_cr_4', 'M1_cr_6']) - self.assertListEqual(p3, ['M1_cr_2', 'M2_cr_3', 'M1_cr_5']) + self.assertListEqual( + p1, + ['M1_carrier_1', 'M2_carrier_2', 'M1_carrier_4', 'M2_carrier_5'] + ) + self.assertListEqual( + p2, + ['M2_carrier_1', 'M1_carrier_3', 'M2_carrier_4', 'M1_carrier_6'] + ) + self.assertListEqual( + p3, + ['M1_carrier_2', 'M2_carrier_3', 'M1_carrier_5'] + ) def test_with_policy_on_fixed_index(self): @@ -157,5 +166,5 @@ def policy(*args): line.run(100, agent=policy) p1, p2, p3 = self.get_parts_at_processes(line) self.assertListEqual(p1, []) - self.assertListEqual(p2, [f'M2_cr_{i}' for i in range(1, 7)]) + self.assertListEqual(p2, [f'M2_carrier_{i}' for i in range(1, 7)]) self.assertListEqual(p3, []) diff --git a/tests/test_workers.py b/tests/test_workers.py index 6ba951e..a772d68 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -138,7 +138,7 @@ def build(self): class TestWorkers(unittest.TestCase): def setUp(self): - self.line = LineWithWorkers() + self.line = LineWithWorkers(realtime=False, factor=0.8) self.worker_cols = [f"pool_W{i}" for i in range(10)] def test_run(self): @@ -153,11 +153,12 @@ def compute_n_workers(self, df): return df def test_turn_on(self): - self.line.run(200, agent=all_to_one) + self.line.run(200, agent=all_to_one, visualize=False) df = self.line.get_observations() df = self.compute_n_workers(df) + # When C3 finishes work after T=50 and before T=70, there is no worker at C3 - self.assertEqual(df[(df.T_end > 60) & (df.T_end < 70)]['C3_n_workers'].sum(), 0) + self.assertEqual(df[(df.T_end > 62) & (df.T_end < 70)]['C3_n_workers'].sum(), 0) self.assertListEqual(df[df.T_end > 80]['C3_n_workers'].unique().tolist(), [10.0]) def test_with_random_shuffle(self):