From 37ff6ac3c5090105d6ee03400cbb862263f9017d Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Wed, 5 Feb 2025 11:36:30 +0100 Subject: [PATCH 01/10] Parser for RCPSP-PS --- data/rcpsp_ps.txt | 546 ++++++++++++++++++++++++++++++++++ example.ipynb | 133 ++++++++- formats/rcpsp_ps.md | 57 ++++ psplib/ProjectInstance.py | 13 +- psplib/__init__.py | 1 + psplib/parse.py | 3 + psplib/parse_rcpsp_ps.py | 43 +++ tests/data/rcpsp_ps.txt | 546 ++++++++++++++++++++++++++++++++++ tests/test_parse.py | 1 + tests/test_parse_mplib.py | 20 +- tests/test_parse_patterson.py | 5 +- tests/test_parse_psplib.py | 11 +- tests/test_parse_rcpsp_max.py | 6 +- tests/test_parse_rcpsp_ps.py | 42 +++ 14 files changed, 1403 insertions(+), 24 deletions(-) create mode 100644 data/rcpsp_ps.txt create mode 100644 formats/rcpsp_ps.md create mode 100644 psplib/parse_rcpsp_ps.py create mode 100644 tests/data/rcpsp_ps.txt create mode 100644 tests/test_parse_rcpsp_ps.py diff --git a/data/rcpsp_ps.txt b/data/rcpsp_ps.txt new file mode 100644 index 0000000..dede5c8 --- /dev/null +++ b/data/rcpsp_ps.txt @@ -0,0 +1,546 @@ +136 4 0 +10 10 10 10 + +0 0 0 0 0 +1 2 1 2 +2 1 2 + +0 0 0 0 0 +2 1 3 1 4 +2 3 4 + +0 0 0 0 0 +1 2 11 12 +2 11 12 + +9 0 0 1 1 +1 1 5 +1 5 + +8 9 9 9 0 +1 1 43 +1 43 + +3 1 1 5 9 +1 3 21 22 6 +3 21 22 6 + +0 0 0 0 0 +2 1 7 1 8 +2 7 8 + +9 0 0 1 1 +1 1 9 +1 9 + +8 9 9 9 0 +1 1 10 +1 10 + +3 1 1 5 9 +1 1 10 +1 10 + +0 0 0 0 0 +1 1 32 +1 32 + +0 0 0 0 0 +2 1 13 1 14 +2 13 14 + +0 0 0 0 0 +2 1 17 1 18 +2 17 18 + +6 1 0 1 1 +1 1 15 +1 15 + +1 9 5 9 8 +1 1 15 +1 15 + +1 5 0 0 6 +1 1 16 +1 16 + +0 0 0 0 0 +1 1 23 +1 23 + +9 0 0 1 1 +1 1 19 +1 19 + +8 9 9 9 0 +1 1 20 +1 20 + +3 1 1 5 9 +1 1 20 +1 20 + +0 0 0 0 0 +1 1 23 +1 23 + +0 0 0 0 0 +2 1 24 1 25 +2 24 25 + +0 0 0 0 0 +1 1 28 +1 28 + +0 0 0 0 0 +1 1 33 +1 33 + +9 0 0 1 1 +1 1 26 +1 26 + +8 9 9 9 0 +1 1 27 +1 27 + +3 1 1 5 9 +1 1 27 +1 27 + +0 0 0 0 0 +1 1 32 +1 32 + +2 1 0 5 1 +2 1 29 1 30 +2 29 30 + +2 7 1 0 8 +1 1 31 +1 31 + +7 7 9 0 6 +1 1 31 +1 31 + +0 0 0 0 0 +1 1 32 +1 32 + +0 0 0 0 0 +1 1 43 +1 43 + +2 1 0 5 1 +2 1 34 1 35 +2 34 35 + +2 7 1 0 8 +1 3 36 37 38 +3 36 37 38 + +7 7 9 0 6 +1 1 69 +1 69 + +0 0 0 0 0 +1 1 39 +1 39 + +0 0 0 0 0 +2 1 44 1 45 +2 44 45 + +0 0 0 0 0 +2 1 47 1 48 +2 47 48 + +2 1 0 5 1 +2 1 40 1 41 +2 40 41 + +2 7 1 0 8 +1 1 42 +1 42 + +7 7 9 0 6 +1 1 42 +1 42 + +0 0 0 0 0 +1 1 68 +1 68 + +0 0 0 0 0 +1 3 51 52 53 +3 51 52 53 + +9 0 0 1 1 +1 1 46 +1 46 + +8 9 9 9 0 +1 1 66 +1 66 + +3 1 1 5 9 +1 1 66 +1 66 + +6 1 0 1 1 +1 1 49 +1 49 + +1 9 5 9 8 +1 1 49 +1 49 + +1 5 0 0 6 +1 1 50 +1 50 + +0 0 0 0 0 +1 1 68 +1 68 + +0 0 0 0 0 +2 1 54 1 55 +2 54 55 + +0 0 0 0 0 +1 1 58 +1 58 + +0 0 0 0 0 +2 1 62 1 63 +2 62 63 + +9 0 0 1 1 +1 1 56 +1 56 + +8 9 9 9 0 +1 1 57 +1 57 + +3 1 1 5 9 +1 1 57 +1 57 + +0 0 0 0 0 +1 1 67 +1 67 + +2 1 0 5 1 +2 1 59 1 60 +2 59 60 + +2 7 1 0 8 +1 1 61 +1 61 + +7 7 9 0 6 +1 1 61 +1 61 + +0 0 0 0 0 +1 1 67 +1 67 + +6 1 0 1 1 +1 1 64 +1 64 + +1 9 5 9 8 +1 1 64 +1 64 + +1 5 0 0 6 +1 1 65 +1 65 + +0 0 0 0 0 +1 1 67 +1 67 + +0 0 0 0 0 +1 1 68 +1 68 + +0 0 0 0 0 +1 1 70 +1 70 + +0 0 0 0 0 +1 1 69 +1 69 + +0 0 0 0 0 +1 1 70 +1 70 + +0 0 0 0 0 +1 2 72 71 +2 72 71 + +0 0 0 0 0 +1 2 73 93 +2 73 93 + +0 0 0 0 0 +1 3 80 78 79 +3 78 79 80 + +0 0 0 0 0 +1 1 74 +1 74 + +2 1 0 5 1 +2 1 75 1 76 +2 75 76 + +2 7 1 0 8 +1 1 77 +1 77 + +7 7 9 0 6 +1 1 77 +1 77 + +0 0 0 0 0 +1 1 99 +1 99 + +0 0 0 0 0 +2 1 81 1 82 +2 81 82 + +0 0 0 0 0 +2 1 85 1 86 +2 85 86 + +0 0 0 0 0 +2 1 89 1 90 +2 89 90 + +6 1 0 1 1 +1 1 83 +1 83 + +1 9 5 9 8 +1 1 83 +1 83 + +1 5 0 0 6 +1 1 84 +1 84 + +0 0 0 0 0 +1 1 94 +1 94 + +6 1 0 1 1 +1 1 87 +1 87 + +1 9 5 9 8 +1 1 87 +1 87 + +1 5 0 0 6 +1 1 88 +1 88 + +0 0 0 0 0 +1 1 94 +1 94 + +9 0 0 1 1 +1 1 91 +1 91 + +8 9 9 9 0 +1 1 92 +1 92 + +3 1 1 5 9 +1 1 92 +1 92 + +0 0 0 0 0 +1 1 94 +1 94 + +0 0 0 0 0 +2 1 95 1 96 +2 95 96 + +0 0 0 0 0 +2 1 100 1 101 +2 100 101 + +6 1 0 1 1 +1 1 97 +1 97 + +1 9 5 9 8 +1 1 97 +1 97 + +1 5 0 0 6 +1 1 98 +1 98 + +0 0 0 0 0 +1 1 99 +1 99 + +0 0 0 0 0 +1 1 104 +1 104 + +6 1 0 1 1 +1 1 102 +1 102 + +1 9 5 9 8 +1 1 102 +1 102 + +1 5 0 0 6 +1 1 103 +1 103 + +0 0 0 0 0 +1 2 105 108 +2 108 105 + +2 1 0 5 1 +1 3 106 107 114 +3 106 107 114 + +0 0 0 0 0 +1 1 113 +1 113 + +0 0 0 0 0 +1 1 116 +1 116 + +0 0 0 0 0 +1 1 120 +1 120 + +0 0 0 0 0 +1 1 109 +1 109 + +2 1 0 5 1 +2 1 110 1 111 +2 110 111 + +2 7 1 0 8 +1 1 112 +1 112 + +7 7 9 0 6 +1 1 112 +1 112 + +0 0 0 0 0 +1 1 132 +1 132 + +2 1 0 5 1 +2 1 128 1 115 +2 128 115 + +0 0 0 0 0 +1 1 124 +1 124 + +7 7 9 0 6 +1 1 130 +1 130 + +2 1 0 5 1 +2 1 117 1 118 +2 117 118 + +2 7 1 0 8 +1 1 119 +1 119 + +7 7 9 0 6 +1 1 119 +1 119 + +0 0 0 0 0 +1 1 129 +1 129 + +2 1 0 5 1 +2 1 121 1 122 +2 121 122 + +2 7 1 0 8 +1 1 123 +1 123 + +7 7 9 0 6 +1 1 123 +1 123 + +0 0 0 0 0 +1 1 129 +1 129 + +2 1 0 5 1 +2 1 125 1 126 +2 125 126 + +2 7 1 0 8 +1 1 127 +1 127 + +7 7 9 0 6 +1 1 127 +1 127 + +0 0 0 0 0 +1 1 129 +1 129 + +2 7 1 0 8 +1 1 130 +1 130 + +0 0 0 0 0 +2 1 131 1 133 +2 131 133 + +0 0 0 0 0 +1 1 132 +1 132 + +2 7 1 0 8 +1 1 134 +1 134 + +0 0 0 0 0 +1 1 135 +1 135 + +7 7 9 0 6 +1 1 134 +1 134 + +0 0 0 0 0 +1 1 135 +1 135 + +0 0 0 0 0 +0 +0 diff --git a/example.ipynb b/example.ipynb index 224182f..64db24f 100644 --- a/example.ipynb +++ b/example.ipynb @@ -19,7 +19,7 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": 4, "id": "4fec4d5b-df0f-48c4-9e55-602203a89176", "metadata": {}, "outputs": [], @@ -336,6 +336,137 @@ "source": [ "instance.projects[:2]" ] + }, + { + "cell_type": "markdown", + "id": "0d50356a-172d-4086-95e0-afd838ea7c56", + "metadata": {}, + "source": [ + "## RCPSP-PS and ASLIB format\n", + "In the RCPSP with flexible project structures (RCPSP-PS), there are choices about which activities to schedule.\n", + "\n", + "1. Activities can be optional - they don't all need to be scheduled\n", + "2. Activities each have a set of associated selection groups\n", + "3. If an activity is scheduled, then for each selection group, exactly one activity must also be scheduled" + ] + }, + { + "cell_type": "markdown", + "id": "ba6e61af-bd49-4871-b932-a78e23d24e0b", + "metadata": {}, + "source": [ + "Our library support two common RCPSP-PS formats." + ] + }, + { + "cell_type": "markdown", + "id": "a915ad69-962b-43f4-bc99-002739bb7eb8", + "metadata": {}, + "source": [ + "### RCPSP-PS" + ] + }, + { + "cell_type": "markdown", + "id": "68832a0e-eca0-407e-ad3b-dad79bc72cc4", + "metadata": {}, + "source": [ + "The \"RCPSP-PS\" format is the format used by [Van der Beek et al. (2024)](https://www.sciencedirect.com/science/article/pii/S0377221724008269)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c7b74459-6107-4e88-a729-7f0cc7d7f76f", + "metadata": {}, + "outputs": [], + "source": [ + "instance = parse(\"data/rcpsp_ps.txt\", instance_format=\"rcpsp_ps\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "bebe5e60-c84a-45c4-a9ef-9d7f34eed561", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(4, 136)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(instance.num_resources, instance.num_activities)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "990c6936-cbf2-47c4-823d-35fbdc2c240f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[1, 2], delays=None, selection_groups=[[1, 2]], optional=False, name='')" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "activity = instance.activities[0] # source\n", + "activity" + ] + }, + { + "cell_type": "markdown", + "id": "529e7add-9c25-4a41-b44b-4fda01f2f5d0", + "metadata": {}, + "source": [ + "The source activity must always be present (`optional=False`).\n", + "It has one selection group consisting of activities 1 and 2.\n", + "This means that either activity 1 or activity 2 must be scheduled." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "1cb5b8c1-a6f3-4297-a327-fb2b4f05a927", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[3, 4], delays=None, selection_groups=[[3], [4]], optional=True, name='')" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "activity = instance.activities[1]\n", + "activity" + ] + }, + { + "cell_type": "markdown", + "id": "a955cdd4-fb27-491f-8ebd-d26737c9c9d7", + "metadata": {}, + "source": [ + "This activity has two selection groups, one with activity 3, and the other with activity 4.\n", + "If this activity is scheduled, then both activity 3 and activity must be scheduled." + ] } ], "metadata": { diff --git a/formats/rcpsp_ps.md b/formats/rcpsp_ps.md new file mode 100644 index 0000000..900ce8c --- /dev/null +++ b/formats/rcpsp_ps.md @@ -0,0 +1,57 @@ +Note: taken from https://data.4tu.nl/articles/dataset/Instances_and_file_format_for_the_Resource_Constrained_Project_Scheduling_Problem_with_a_flexible_Project_Structure/21106768 + +---- + +The input file format is as following (# of means number of) +Counting starts at 0 + + [# of activities] [# of renewable resources] [#number of nonrewable resources] + [resource availabilities] + + [duration] [resource requirements per resource type] + [# of selection-groups] [[# of or selection successors] [selection group successor1] [selection group successor2]...] (repeat) + [# of time precedence successors] [successor1] [successor2] ... + + + + +To give a small example: + + 5 1 0 + 10 + + 0 0 + 2 2 1 2 1 3 + 3 1 2 3 + + 2 5 + 1 1 4 + 1 4 + + 2 6 + 1 1 4 + 1 4 + + 3 10 + 1 1 4 + 1 4 + + 4 0 + 0 + 0 + +This means: + + 5 1 0 + 10 6 8 2 + 1.05 1.06 + +In total 5 activities, with 1 renewable resource (with availability 10), and 0 nonrenewable resources + + 0 0 + 2 2 1 2 1 4 + 3 1 2 3 + +First line: Activity 0 with a duration of 0 and no resource requirements +Second line: Two selection-groups. The first one has two successor nodes +Third line: Three time successors: Activities 1 2 and 3 diff --git a/psplib/ProjectInstance.py b/psplib/ProjectInstance.py index 5e8abf3..dabe459 100644 --- a/psplib/ProjectInstance.py +++ b/psplib/ProjectInstance.py @@ -1,5 +1,4 @@ -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field @dataclass @@ -52,6 +51,12 @@ class Activity: the length of this list must be equal to the length of `successors`. Delays are used for RCPSP/max instances, where the precedence relationship is defined as ``start(pred) + delay <= start(succ)``. + selection_groups + The selection groups of this activity. If the current activity is + scheduled, then for each group, exactly one activity must be scheduled. + This is used for RCPSP-PS instances. Default empty list. + optional + Whether this activity is optional or not. Default ``False``. name Optional name of the activity to identify this activity. This is helpful to map this activity back to the original problem instance. @@ -59,7 +64,9 @@ class Activity: modes: list[Mode] successors: list[int] - delays: Optional[list[int]] = None + delays: list[int] | None = None + selection_groups: list[list[int]] = field(default_factory=list) + optional: bool = False name: str = "" def __post_init__(self): diff --git a/psplib/__init__.py b/psplib/__init__.py index 4af003b..452f743 100644 --- a/psplib/__init__.py +++ b/psplib/__init__.py @@ -3,4 +3,5 @@ from .parse_patterson import parse_patterson as parse_patterson from .parse_psplib import parse_psplib as parse_psplib from .parse_rcpsp_max import parse_rcpsp_max as parse_rcpsp_max +from .parse_rcpsp_ps import parse_rcpsp_ps as parse_rcpsp_ps from .ProjectInstance import ProjectInstance as ProjectInstance diff --git a/psplib/parse.py b/psplib/parse.py index 9e072c1..b518389 100644 --- a/psplib/parse.py +++ b/psplib/parse.py @@ -5,6 +5,7 @@ from .parse_patterson import parse_patterson from .parse_psplib import parse_psplib from .parse_rcpsp_max import parse_rcpsp_max +from .parse_rcpsp_ps import parse_rcpsp_ps from .ProjectInstance import ProjectInstance @@ -35,5 +36,7 @@ def parse( return parse_rcpsp_max(loc) elif instance_format == "mplib": return parse_mplib(loc) + elif instance_format == "rcpsp_ps": + return parse_rcpsp_ps(loc) raise ValueError(f"Unknown instance format: {instance_format}") diff --git a/psplib/parse_rcpsp_ps.py b/psplib/parse_rcpsp_ps.py new file mode 100644 index 0000000..dc3a5dc --- /dev/null +++ b/psplib/parse_rcpsp_ps.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from .ProjectInstance import Activity, Mode, Project, ProjectInstance, Resource + + +def parse_rcpsp_ps(instance_loc: str | Path) -> ProjectInstance: + """ + Parses a RCPSP-PS formatted instance from Van der Beek et al. (2024). + """ + with open(instance_loc, "r") as fh: + lines = iter(line.strip() for line in fh.readlines() if line.strip()) + + num_activities, num_renewable, _ = map(int, next(lines).split()) + capacities = list(map(int, next(lines).split())) + + resources = [ + Resource(capacity, idx < num_renewable) # resources are ordered + for idx, capacity in enumerate(capacities) + ] + activities = [] + + for idx in range(num_activities): + duration, *demands = map(int, next(lines).split()) + line = map(int, next(lines).split()) + + groups = [] + num_groups = next(line) + for _ in range(num_groups): + num_successors = next(line) + groups.append([next(line) for _ in range(num_successors)]) + + num_successors, *successors = map(int, next(lines).split()) + activities.append( + Activity( + [Mode(duration, demands)], + successors, + selection_groups=groups, + optional=idx > 0, # source activity is not optional + ) + ) + + project = Project(list(range(num_activities))) + return ProjectInstance(resources, activities, [project]) diff --git a/tests/data/rcpsp_ps.txt b/tests/data/rcpsp_ps.txt new file mode 100644 index 0000000..dede5c8 --- /dev/null +++ b/tests/data/rcpsp_ps.txt @@ -0,0 +1,546 @@ +136 4 0 +10 10 10 10 + +0 0 0 0 0 +1 2 1 2 +2 1 2 + +0 0 0 0 0 +2 1 3 1 4 +2 3 4 + +0 0 0 0 0 +1 2 11 12 +2 11 12 + +9 0 0 1 1 +1 1 5 +1 5 + +8 9 9 9 0 +1 1 43 +1 43 + +3 1 1 5 9 +1 3 21 22 6 +3 21 22 6 + +0 0 0 0 0 +2 1 7 1 8 +2 7 8 + +9 0 0 1 1 +1 1 9 +1 9 + +8 9 9 9 0 +1 1 10 +1 10 + +3 1 1 5 9 +1 1 10 +1 10 + +0 0 0 0 0 +1 1 32 +1 32 + +0 0 0 0 0 +2 1 13 1 14 +2 13 14 + +0 0 0 0 0 +2 1 17 1 18 +2 17 18 + +6 1 0 1 1 +1 1 15 +1 15 + +1 9 5 9 8 +1 1 15 +1 15 + +1 5 0 0 6 +1 1 16 +1 16 + +0 0 0 0 0 +1 1 23 +1 23 + +9 0 0 1 1 +1 1 19 +1 19 + +8 9 9 9 0 +1 1 20 +1 20 + +3 1 1 5 9 +1 1 20 +1 20 + +0 0 0 0 0 +1 1 23 +1 23 + +0 0 0 0 0 +2 1 24 1 25 +2 24 25 + +0 0 0 0 0 +1 1 28 +1 28 + +0 0 0 0 0 +1 1 33 +1 33 + +9 0 0 1 1 +1 1 26 +1 26 + +8 9 9 9 0 +1 1 27 +1 27 + +3 1 1 5 9 +1 1 27 +1 27 + +0 0 0 0 0 +1 1 32 +1 32 + +2 1 0 5 1 +2 1 29 1 30 +2 29 30 + +2 7 1 0 8 +1 1 31 +1 31 + +7 7 9 0 6 +1 1 31 +1 31 + +0 0 0 0 0 +1 1 32 +1 32 + +0 0 0 0 0 +1 1 43 +1 43 + +2 1 0 5 1 +2 1 34 1 35 +2 34 35 + +2 7 1 0 8 +1 3 36 37 38 +3 36 37 38 + +7 7 9 0 6 +1 1 69 +1 69 + +0 0 0 0 0 +1 1 39 +1 39 + +0 0 0 0 0 +2 1 44 1 45 +2 44 45 + +0 0 0 0 0 +2 1 47 1 48 +2 47 48 + +2 1 0 5 1 +2 1 40 1 41 +2 40 41 + +2 7 1 0 8 +1 1 42 +1 42 + +7 7 9 0 6 +1 1 42 +1 42 + +0 0 0 0 0 +1 1 68 +1 68 + +0 0 0 0 0 +1 3 51 52 53 +3 51 52 53 + +9 0 0 1 1 +1 1 46 +1 46 + +8 9 9 9 0 +1 1 66 +1 66 + +3 1 1 5 9 +1 1 66 +1 66 + +6 1 0 1 1 +1 1 49 +1 49 + +1 9 5 9 8 +1 1 49 +1 49 + +1 5 0 0 6 +1 1 50 +1 50 + +0 0 0 0 0 +1 1 68 +1 68 + +0 0 0 0 0 +2 1 54 1 55 +2 54 55 + +0 0 0 0 0 +1 1 58 +1 58 + +0 0 0 0 0 +2 1 62 1 63 +2 62 63 + +9 0 0 1 1 +1 1 56 +1 56 + +8 9 9 9 0 +1 1 57 +1 57 + +3 1 1 5 9 +1 1 57 +1 57 + +0 0 0 0 0 +1 1 67 +1 67 + +2 1 0 5 1 +2 1 59 1 60 +2 59 60 + +2 7 1 0 8 +1 1 61 +1 61 + +7 7 9 0 6 +1 1 61 +1 61 + +0 0 0 0 0 +1 1 67 +1 67 + +6 1 0 1 1 +1 1 64 +1 64 + +1 9 5 9 8 +1 1 64 +1 64 + +1 5 0 0 6 +1 1 65 +1 65 + +0 0 0 0 0 +1 1 67 +1 67 + +0 0 0 0 0 +1 1 68 +1 68 + +0 0 0 0 0 +1 1 70 +1 70 + +0 0 0 0 0 +1 1 69 +1 69 + +0 0 0 0 0 +1 1 70 +1 70 + +0 0 0 0 0 +1 2 72 71 +2 72 71 + +0 0 0 0 0 +1 2 73 93 +2 73 93 + +0 0 0 0 0 +1 3 80 78 79 +3 78 79 80 + +0 0 0 0 0 +1 1 74 +1 74 + +2 1 0 5 1 +2 1 75 1 76 +2 75 76 + +2 7 1 0 8 +1 1 77 +1 77 + +7 7 9 0 6 +1 1 77 +1 77 + +0 0 0 0 0 +1 1 99 +1 99 + +0 0 0 0 0 +2 1 81 1 82 +2 81 82 + +0 0 0 0 0 +2 1 85 1 86 +2 85 86 + +0 0 0 0 0 +2 1 89 1 90 +2 89 90 + +6 1 0 1 1 +1 1 83 +1 83 + +1 9 5 9 8 +1 1 83 +1 83 + +1 5 0 0 6 +1 1 84 +1 84 + +0 0 0 0 0 +1 1 94 +1 94 + +6 1 0 1 1 +1 1 87 +1 87 + +1 9 5 9 8 +1 1 87 +1 87 + +1 5 0 0 6 +1 1 88 +1 88 + +0 0 0 0 0 +1 1 94 +1 94 + +9 0 0 1 1 +1 1 91 +1 91 + +8 9 9 9 0 +1 1 92 +1 92 + +3 1 1 5 9 +1 1 92 +1 92 + +0 0 0 0 0 +1 1 94 +1 94 + +0 0 0 0 0 +2 1 95 1 96 +2 95 96 + +0 0 0 0 0 +2 1 100 1 101 +2 100 101 + +6 1 0 1 1 +1 1 97 +1 97 + +1 9 5 9 8 +1 1 97 +1 97 + +1 5 0 0 6 +1 1 98 +1 98 + +0 0 0 0 0 +1 1 99 +1 99 + +0 0 0 0 0 +1 1 104 +1 104 + +6 1 0 1 1 +1 1 102 +1 102 + +1 9 5 9 8 +1 1 102 +1 102 + +1 5 0 0 6 +1 1 103 +1 103 + +0 0 0 0 0 +1 2 105 108 +2 108 105 + +2 1 0 5 1 +1 3 106 107 114 +3 106 107 114 + +0 0 0 0 0 +1 1 113 +1 113 + +0 0 0 0 0 +1 1 116 +1 116 + +0 0 0 0 0 +1 1 120 +1 120 + +0 0 0 0 0 +1 1 109 +1 109 + +2 1 0 5 1 +2 1 110 1 111 +2 110 111 + +2 7 1 0 8 +1 1 112 +1 112 + +7 7 9 0 6 +1 1 112 +1 112 + +0 0 0 0 0 +1 1 132 +1 132 + +2 1 0 5 1 +2 1 128 1 115 +2 128 115 + +0 0 0 0 0 +1 1 124 +1 124 + +7 7 9 0 6 +1 1 130 +1 130 + +2 1 0 5 1 +2 1 117 1 118 +2 117 118 + +2 7 1 0 8 +1 1 119 +1 119 + +7 7 9 0 6 +1 1 119 +1 119 + +0 0 0 0 0 +1 1 129 +1 129 + +2 1 0 5 1 +2 1 121 1 122 +2 121 122 + +2 7 1 0 8 +1 1 123 +1 123 + +7 7 9 0 6 +1 1 123 +1 123 + +0 0 0 0 0 +1 1 129 +1 129 + +2 1 0 5 1 +2 1 125 1 126 +2 125 126 + +2 7 1 0 8 +1 1 127 +1 127 + +7 7 9 0 6 +1 1 127 +1 127 + +0 0 0 0 0 +1 1 129 +1 129 + +2 7 1 0 8 +1 1 130 +1 130 + +0 0 0 0 0 +2 1 131 1 133 +2 131 133 + +0 0 0 0 0 +1 1 132 +1 132 + +2 7 1 0 8 +1 1 134 +1 134 + +0 0 0 0 0 +1 1 135 +1 135 + +7 7 9 0 6 +1 1 134 +1 134 + +0 0 0 0 0 +1 1 135 +1 135 + +0 0 0 0 0 +0 +0 diff --git a/tests/test_parse.py b/tests/test_parse.py index 7fbd475..00c2fdd 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -11,6 +11,7 @@ ("data/RG300_1.rcp", "patterson"), ("data/MPLIB1_Set1_0.rcmp", "mplib"), ("data/UBO10_01.sch", "rcpsp_max"), + ("data/rcpsp_ps.txt", "rcpsp_ps"), ], ) def test_parse(loc, instance_format): diff --git a/tests/test_parse_mplib.py b/tests/test_parse_mplib.py index 3fc9738..1ce5998 100644 --- a/tests/test_parse_mplib.py +++ b/tests/test_parse_mplib.py @@ -18,11 +18,6 @@ def test_mplib_set1(): assert_equal(capacities, [56, 56, 56, 56]) assert_equal(renewables, [True, True, True, True]) - assert_equal(instance.num_projects, 6) - for project in instance.projects: - assert_equal(project.num_activities, 62) - assert_equal(project.release_date, 0) - assert_equal(instance.num_activities, 6 * 62) activity = instance.activities[0] @@ -35,6 +30,11 @@ def test_mplib_set1(): assert_equal(activity.modes[0].demands, [0, 0, 0, 0]) assert_equal(activity.modes[0].duration, 0) + assert_equal(instance.num_projects, 6) + for project in instance.projects: + assert_equal(project.num_activities, 62) + assert_equal(project.release_date, 0) + def test_mplib_set2(): """ @@ -49,11 +49,6 @@ def test_mplib_set2(): assert_equal(capacities, [48, 48, 46, 50, 48]) assert_equal(renewables, [True, True, True, True, True]) - assert_equal(instance.num_projects, 10) - for project in instance.projects: - assert_equal(project.num_activities, 52) - assert_equal(project.release_date, 0) - assert_equal(instance.num_activities, 10 * 52) activity = instance.activities[-51] # second activity of last project @@ -66,3 +61,8 @@ def test_mplib_set2(): assert_equal(activity.num_modes, 1) assert_equal(activity.modes[0].demands, [8, 4, 3, 5, 1]) assert_equal(activity.modes[0].duration, 7) + + assert_equal(instance.num_projects, 10) + for project in instance.projects: + assert_equal(project.num_activities, 52) + assert_equal(project.release_date, 0) diff --git a/tests/test_parse_patterson.py b/tests/test_parse_patterson.py index dc33c82..5fd06c0 100644 --- a/tests/test_parse_patterson.py +++ b/tests/test_parse_patterson.py @@ -18,8 +18,6 @@ def test_rg300(): assert_equal(capacities, [10, 10, 10, 10]) assert_equal(renewables, [True, True, True, True]) - assert_equal(instance.num_projects, 1) - assert_equal(instance.projects[0].num_activities, 302) assert_equal(instance.num_activities, 302) activity = instance.activities[1] # second activity @@ -36,3 +34,6 @@ def test_rg300(): assert_equal(activity.num_modes, 1) assert_equal(activity.modes[0].demands, [0, 1, 0, 0]) assert_equal(activity.modes[0].duration, 3) + + assert_equal(instance.num_projects, 1) + assert_equal(instance.projects[0].num_activities, 302) diff --git a/tests/test_parse_psplib.py b/tests/test_parse_psplib.py index 7d1b986..c0ee3e5 100644 --- a/tests/test_parse_psplib.py +++ b/tests/test_parse_psplib.py @@ -18,8 +18,6 @@ def test_instance_single_mode(): assert_equal(capacities, [12, 9, 37, 53]) assert_equal(renewables, [True, True, False, False]) - assert_equal(instance.num_projects, 1) - assert_equal(instance.projects[0].num_activities, 18) assert_equal(instance.num_activities, 18) activity = instance.activities[1] # second activity (jobnr. 2) @@ -31,6 +29,9 @@ def test_instance_single_mode(): assert_equal(activity.modes[0].duration, 2) assert_equal(activity.modes[0].demands, [0, 4, 8, 0]) + assert_equal(instance.num_projects, 1) + assert_equal(instance.projects[0].num_activities, 18) + def test_instance_mmlib(): """ @@ -46,9 +47,6 @@ def test_instance_mmlib(): assert_equal(capacities, [33, 33, 247, 248]) assert_equal(renewables, [True, True, False, False]) - assert_equal(instance.num_projects, 1) - assert_equal(instance.projects[0].num_activities, 52) - assert_equal(instance.num_activities, 52) activity = instance.activities[1] # second activity (jobnr. 2) @@ -63,3 +61,6 @@ def test_instance_mmlib(): assert_equal(activity.modes[1].demands, [5, 5, 2, 6]) assert_equal(activity.modes[2].duration, 4) assert_equal(activity.modes[2].demands, [4, 5, 2, 6]) + + assert_equal(instance.num_projects, 1) + assert_equal(instance.projects[0].num_activities, 52) diff --git a/tests/test_parse_rcpsp_max.py b/tests/test_parse_rcpsp_max.py index 41512e5..e7637f6 100644 --- a/tests/test_parse_rcpsp_max.py +++ b/tests/test_parse_rcpsp_max.py @@ -18,15 +18,15 @@ def test_ubo10(): assert_equal(capacities, [10, 10, 10, 10, 10]) assert_equal(renewables, [True, True, True, True, True]) - assert_equal(instance.num_projects, 1) - assert_equal(instance.projects[0].num_activities, 12) assert_equal(instance.num_activities, 12) activity = instance.activities[2] # third activity - assert_equal(activity.successors, [4, 11, 7]) assert_equal(activity.delays, [5, 9, 0]) assert_equal(activity.num_modes, 1) assert_equal(activity.modes[0].demands, [10, 8, 0, 8, 10]) assert_equal(activity.modes[0].duration, 9) + + assert_equal(instance.num_projects, 1) + assert_equal(instance.projects[0].num_activities, 12) diff --git a/tests/test_parse_rcpsp_ps.py b/tests/test_parse_rcpsp_ps.py new file mode 100644 index 0000000..9def29c --- /dev/null +++ b/tests/test_parse_rcpsp_ps.py @@ -0,0 +1,42 @@ +from numpy.testing import assert_equal + +from psplib import parse_rcpsp_ps + +from .utils import relative + + +def test_rcpsp_ps(): + """ + Tests that the instance ``rcpsp_ps.txt`` is correctly parsed. + """ + instance = parse_rcpsp_ps(relative("data/rcpsp_ps.txt")) + + assert_equal(instance.num_resources, 4) + + capacities = [res.capacity for res in instance.resources] + renewables = [res.renewable for res in instance.resources] + + assert_equal(capacities, [10, 10, 10, 10]) + assert_equal(renewables, [True, True, True, True]) + + assert_equal(instance.num_activities, 136) + + activity = instance.activities[0] # first activity (source) + assert_equal(activity.successors, [1, 2]) + assert_equal(activity.optional, False) # source always present + assert_equal(activity.selection_groups, [[1, 2]]) + + assert_equal(activity.num_modes, 1) + assert_equal(activity.modes[0].demands, [0, 0, 0, 0]) + assert_equal(activity.modes[0].duration, 0) + + activity = instance.activities[3] # fourth activity + assert_equal(activity.successors, [5]) + assert_equal(activity.selection_groups, [[5]]) + + assert_equal(activity.num_modes, 1) + assert_equal(activity.modes[0].demands, [0, 0, 1, 1]) + assert_equal(activity.modes[0].duration, 9) + + assert_equal(instance.num_projects, 1) + assert_equal(instance.projects[0].num_activities, 136) From 7db37e224c719287ca29e3b5e370657fd027b37b Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Wed, 5 Feb 2025 13:46:03 +0100 Subject: [PATCH 02/10] Initial commit parse aslib This is the original parser file that I used during prototyping --- psplib/parse_aslib.py | 240 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 psplib/parse_aslib.py diff --git a/psplib/parse_aslib.py b/psplib/parse_aslib.py new file mode 100644 index 0000000..57a378c --- /dev/null +++ b/psplib/parse_aslib.py @@ -0,0 +1,240 @@ +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path + +import matplotlib.pyplot as plt +import networkx as nx + +from psplib import ProjectInstance, parse + + +@dataclass +class AlternativeSubgraph: + """ + Represents a single alternative subgraph in an ASLIB instance. + + Parameters + ---------- + branches + A list of branches in the subgraph. Each branch is a list of activity + indices that are part of the branch. + """ + + branches: list[list[int]] + + +def parse_aslib(loc: str | Path) -> list[AlternativeSubgraph]: + """ + Parses an ASLIB-formatted instance from a file. This format is used for + RCPSP instances with alternative subgraphs. + + Note + ---- + This only parses the "b" files from the ASLIB instance. The "a" files + are parsed as Patterson-formatted instances. + + Parameters + ---------- + loc + The location of the instance. + + Returns + ------- + list[AlternativeSubgraph] + The alternative subgraphs data. + """ + with open(loc, "r") as fh: + lines = iter(line.strip() for line in fh.readlines() if line.strip()) + + pct_flex, pct_nested, pct_linked = map(float, next(lines).split()) + num_subgraphs = int(next(lines)) + total_branches = 1 # first branch is always the dummy branch + subgraphs = [] + + for _ in range(num_subgraphs): + num_branches, *branch_idcs = map(int, next(lines).split()) + total_branches += num_branches + branch_idcs = [idx - 1 for idx in branch_idcs] + subgraphs.append(branch_idcs) + + branches: list[list[int]] = [[] for _ in range(total_branches)] + for activity, line in enumerate(lines): + num_braches, *branch_idcs = map(int, line.split()) + for idx in branch_idcs: + branches[idx - 1].append(activity) + + result = [AlternativeSubgraph([branches[0]])] + result += [ + AlternativeSubgraph([branches[idx] for idx in branch_idcs]) + for branch_idcs in subgraphs + ] + return result + + +def to_networkx_graph(instance: ProjectInstance) -> nx.DiGraph: + """ + Converts a ProjectInstance to a networkX DiGraph. + """ + G = nx.DiGraph() + + for idx, activity in enumerate(instance.activities): + G.add_node(idx) + for succ in activity.successors: + G.add_edge(idx, succ) + + return G + + +def plot_graph(G: nx.DiGraph, subgraphs: list[AlternativeSubgraph]): + """ + Plot the graph using networkx with multipartite layout and coloring + nodes based on branch membership. + """ + + assert nx.is_directed_acyclic_graph(G) + + for layer, nodes in enumerate(nx.topological_generations(G)): + for node in nodes: + G.nodes[node]["layer"] = layer + + pos = nx.multipartite_layout(G, subset_key="layer") + colors = color_nodes(G, subgraphs) + + fig, ax = plt.subplots() + nx.draw_networkx( + G, + pos=pos, + ax=ax, + node_color=colors, + cmap=plt.cm.get_cmap("tab10"), + ) + ax.set_title("DAG layout in topological order") + fig.tight_layout() + plt.show() + + +def color_nodes(G, graphs): + """ + Assign colors to nodes based on their branch membership. + """ + colors = [] + branches = [branch for graph in graphs for branch in graph.branches] + for node in G.nodes: + for idx, branch in enumerate(branches): + if node in branch: + colors.append(idx) + break + else: + colors.append(-1) + return colors + + +def write_rcpsp_ps(loc, instance, tasks): + """ + Writes instance in "van der Beek (2024)" format. + """ + with open(loc, "w") as fh: + + def write(*args): + fh.write(" ".join(map(str, args)) + "\n") + + write(instance.num_activities, instance.num_resources, 0) + write(*[res.capacity for res in instance.resources]) + + fixed = [idx for idx, task in enumerate(tasks) if not task.optional] + write(*fixed) + + for task in tasks: + write() + write(*([task.duration] + task.demands)) + + groups_line = [len(task.groups)] + for group in task.groups: + groups_line.append(len(group)) + groups_line.extend(group) + + write(*groups_line) + write(len(task.successors), *task.successors) + + +@dataclass +class VDBTask: + duration: int + demands: list[int] + successors: list[int] + optional: bool + groups: list[list[int]] + + +def subgraphs2vdbtasks( + instance: ProjectInstance, subgraphs: list[AlternativeSubgraph] +) -> list[VDBTask]: + """ + Converts the RCPSP-AS instance to a list of tasks for VanDerBeek-format. + """ + G = to_networkx_graph(instance) + top_gen = nx.topological_generations(G) + order = [node for nodes in top_gen for node in nodes] + + fixed_graphs, alternative_graphs = subgraphs[0], subgraphs[1:] + fixed_activities = fixed_graphs.branches[0] + + all_branching = [] # all branching activities + groups = defaultdict(list) + + for graph in alternative_graphs: + # The branching activities are the lowest-indexed activities in each + # branch. This works because we have a DAG: the lowest-indexed activity + # is guaranteed to be the first activity in the branch. (TODO verify.) + branching = [min(b, key=order.index) for b in graph.branches] + + # The branching arcs are the arcs that go from the principal activity + # to the branching activities. We can find the principal activity by + # finding the sole activity that goes to the branching activities. + branching_arcs = [(u, v) for u, v in G.edges if v in branching] + candidates = {u for (u, _) in branching_arcs} + assert len(candidates) == 1 # should be only 1 principal activity + principal = candidates.pop() + + all_branching.extend(branching) + groups[principal].append(branching) + + # For all remaining edges, we add another unit selection group if + # v is not a branching activity. Edges with v as a branching activity + # are already covered by the groups above. + for u, v in G.edges: + if v not in all_branching: + groups[u].append([v]) + + tasks = [] + for idx, activity in enumerate(instance.activities): + duration = activity.modes[0].duration + demands = activity.modes[0].demands + successors = activity.successors + task = VDBTask( + duration, + demands, + successors, + idx not in fixed_activities, + groups[idx], + ) + tasks.append(task) + + for task in tasks: + # In RCPSP-AS, all timing successors are also selection successors. + select_succ = [succ for group in task.groups for succ in group] + assert sorted(select_succ) == sorted(task.successors) + + return tasks + + +if __name__ == "__main__": + for instance_idx in range(1010): + instance_loc = f"tmp/ASLIB/ASLIB0/aslib0_{instance_idx}a.RCP" + instance = parse(instance_loc, instance_format="patterson") + subgraphs = parse_aslib(instance_loc.replace("a.RCP", "b.RCP")) + tasks = subgraphs2vdbtasks(instance, subgraphs) + + loc = f"tmp/rcpsp-ps/instances/ASLIB0/aslib0_{instance_idx}a.txt" + write_rcpsp_ps(loc, instance, tasks) + print(instance_idx) From fbe88cee4e0dbfa0628e34fd926dfcab425c1027 Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Wed, 5 Feb 2025 16:43:10 +0100 Subject: [PATCH 03/10] Parse ASLIB instances --- data/aslib0_0.rcp | 251 +++++++++++++++++++++++++++++++ psplib/__init__.py | 1 + psplib/parse.py | 3 + psplib/parse_aslib.py | 305 ++++++++++++++++++-------------------- tests/data/aslib0_0.rcp | 251 +++++++++++++++++++++++++++++++ tests/test_parse_aslib.py | 42 ++++++ 6 files changed, 692 insertions(+), 161 deletions(-) create mode 100644 data/aslib0_0.rcp create mode 100644 tests/data/aslib0_0.rcp create mode 100644 tests/test_parse_aslib.py diff --git a/data/aslib0_0.rcp b/data/aslib0_0.rcp new file mode 100644 index 0000000..d90416f --- /dev/null +++ b/data/aslib0_0.rcp @@ -0,0 +1,251 @@ +122 5 +10 10 10 10 10 + +0 0 0 0 0 0 5 2 14 26 38 50 +0 0 0 0 0 0 6 3 4 5 6 7 8 +1 0 0 1 0 0 2 10 9 +1 0 0 5 0 0 2 10 9 +7 0 0 4 0 0 2 10 9 +1 0 0 4 0 0 2 10 9 +5 0 0 3 0 0 2 10 9 +6 0 0 1 0 0 1 9 +3 0 0 1 0 0 2 12 11 +8 0 0 2 0 0 2 12 11 +3 0 0 2 0 0 1 13 +9 0 0 2 0 0 1 13 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 6 15 16 18 19 20 24 +6 0 0 0 0 1 2 23 22 +5 0 0 0 0 3 1 17 +9 0 0 0 0 2 1 21 +5 0 0 0 0 2 1 21 +2 0 0 0 0 5 1 21 +9 0 0 0 0 2 1 21 +1 0 0 0 0 2 1 25 +4 0 0 0 0 2 1 25 +1 0 0 0 0 4 1 25 +1 0 0 0 0 2 1 25 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 6 27 28 29 30 32 36 +10 0 0 0 1 0 3 35 34 33 +2 0 0 0 2 0 2 34 31 +8 0 0 0 2 0 2 34 33 +1 0 0 0 1 0 1 31 +1 0 0 0 4 0 1 33 +3 0 0 0 5 0 1 33 +4 0 0 0 4 0 1 37 +2 0 0 0 1 0 1 37 +8 0 0 0 3 0 1 37 +9 0 0 0 2 0 1 37 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 3 39 40 43 +4 0 0 0 1 0 3 48 42 41 +8 0 0 0 3 0 3 47 44 41 +5 0 0 0 3 0 2 46 45 +5 0 0 0 3 0 2 47 46 +10 0 0 0 1 0 1 44 +6 0 0 0 2 0 1 45 +2 0 0 0 4 0 1 49 +2 0 0 0 2 0 1 49 +3 0 0 0 2 0 1 49 +10 0 0 0 4 0 1 49 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 4 51 52 53 56 +5 1 0 0 0 0 4 60 59 58 54 +10 2 0 0 0 0 4 59 58 55 54 +2 2 0 0 0 0 3 60 59 57 +9 4 0 0 0 0 1 57 +2 2 0 0 0 0 1 57 +6 1 0 0 0 0 1 58 +1 3 0 0 0 0 1 61 +4 1 0 0 0 0 1 61 +7 4 0 0 0 0 1 61 +10 5 0 0 0 0 1 61 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 2 63 75 +0 0 0 0 0 0 3 64 65 69 +5 0 0 1 0 0 3 68 67 66 +6 0 0 2 0 0 3 68 67 66 +5 0 0 2 0 0 4 73 72 71 70 +3 0 0 3 0 0 2 72 70 +9 0 0 5 0 0 2 71 70 +1 0 0 3 0 0 2 71 70 +4 0 0 4 0 0 1 74 +5 0 0 2 0 0 1 74 +3 0 0 1 0 0 1 74 +10 0 0 2 0 0 1 74 +0 0 0 0 0 0 1 87 +0 0 0 0 0 0 6 76 78 80 83 84 85 +6 0 0 0 0 1 1 77 +3 0 0 0 0 3 2 82 81 +7 0 0 0 0 2 1 79 +9 0 0 0 0 1 1 81 +6 0 0 0 0 5 1 81 +4 0 0 0 0 1 1 86 +10 0 0 0 0 3 1 86 +7 0 0 0 0 1 1 86 +2 0 0 0 0 3 1 86 +8 0 0 0 0 5 1 86 +0 0 0 0 0 0 1 87 +0 0 0 0 0 0 5 88 89 90 91 92 +1 0 0 0 1 0 4 97 96 95 93 +10 0 0 0 3 0 3 97 95 94 +1 0 0 0 3 0 3 96 95 94 +4 0 0 0 3 0 2 96 93 +2 0 0 0 3 0 2 95 93 +10 0 0 0 1 0 1 94 +3 0 0 0 4 0 1 98 +10 0 0 0 2 0 1 98 +7 0 0 0 3 0 1 98 +4 0 0 0 2 0 1 98 +0 0 0 0 0 0 1 99 +0 0 0 0 0 0 7 100 102 103 106 107 108 109 +3 0 0 0 1 0 1 101 +7 0 0 0 2 0 2 105 104 +9 0 0 0 4 0 1 104 +8 0 0 0 2 0 1 104 +8 0 0 0 2 0 1 110 +1 0 0 0 3 0 1 110 +7 0 0 0 3 0 1 110 +6 0 0 0 3 0 1 110 +10 0 0 0 2 0 1 110 +2 0 0 0 3 0 1 110 +0 0 0 0 0 0 1 111 +0 0 0 0 0 0 3 112 113 114 +10 0 0 0 0 1 4 121 120 118 115 +3 0 0 0 0 3 3 120 119 115 +6 0 0 0 0 3 3 119 117 116 +9 0 0 0 0 2 1 117 +5 0 0 0 0 2 1 118 +8 0 0 0 0 3 1 122 +8 0 0 0 0 3 1 122 +7 0 0 0 0 3 1 122 +8 0 0 0 0 3 1 122 +2 0 0 0 0 2 1 122 +0 0 0 0 0 0 0 +0.250000 0.000000 0.000000 +2 +5 2 3 4 5 6 +2 7 8 +1 1 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 1 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 diff --git a/psplib/__init__.py b/psplib/__init__.py index 452f743..7193308 100644 --- a/psplib/__init__.py +++ b/psplib/__init__.py @@ -1,4 +1,5 @@ from .parse import parse as parse +from .parse_aslib import parse_aslib as parse_aslib from .parse_mplib import parse_mplib as parse_mplib from .parse_patterson import parse_patterson as parse_patterson from .parse_psplib import parse_psplib as parse_psplib diff --git a/psplib/parse.py b/psplib/parse.py index b518389..dd1bb59 100644 --- a/psplib/parse.py +++ b/psplib/parse.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Union +from .parse_aslib import parse_aslib from .parse_mplib import parse_mplib from .parse_patterson import parse_patterson from .parse_psplib import parse_psplib @@ -38,5 +39,7 @@ def parse( return parse_mplib(loc) elif instance_format == "rcpsp_ps": return parse_rcpsp_ps(loc) + elif instance_format == "aslib": + return parse_aslib(loc) raise ValueError(f"Unknown instance format: {instance_format}") diff --git a/psplib/parse_aslib.py b/psplib/parse_aslib.py index 57a378c..e26c91f 100644 --- a/psplib/parse_aslib.py +++ b/psplib/parse_aslib.py @@ -1,11 +1,15 @@ -from collections import defaultdict +from collections import defaultdict, deque from dataclasses import dataclass +from itertools import chain from pathlib import Path -import matplotlib.pyplot as plt -import networkx as nx - -from psplib import ProjectInstance, parse +from .ProjectInstance import ( + Activity, + Mode, + Project, + ProjectInstance, + Resource, +) @dataclass @@ -23,29 +27,32 @@ class AlternativeSubgraph: branches: list[list[int]] -def parse_aslib(loc: str | Path) -> list[AlternativeSubgraph]: +def _parse_part_a(lines): """ - Parses an ASLIB-formatted instance from a file. This format is used for - RCPSP instances with alternative subgraphs. + Part (a) of ASLIB instance is formatted as Patterson instance. + """ + num_activities, num_resources = map(int, next(lines).split()) - Note - ---- - This only parses the "b" files from the ASLIB instance. The "a" files - are parsed as Patterson-formatted instances. + # Instances without resources do not have an availability line. + capacities = list(map(int, next(lines).split())) if num_resources else [] + resources = [Resource(capacity=cap, renewable=True) for cap in capacities] - Parameters - ---------- - loc - The location of the instance. + activities = [] + for _ in range(num_activities): + values = map(int, next(lines).split()) + duration = int(next(values)) + demands = [int(next(values)) for _ in range(num_resources)] + num_successors = int(next(values)) + successors = [int(next(values)) - 1 for _ in range(num_successors)] + activities.append(Activity([Mode(duration, demands)], successors)) - Returns - ------- - list[AlternativeSubgraph] - The alternative subgraphs data. - """ - with open(loc, "r") as fh: - lines = iter(line.strip() for line in fh.readlines() if line.strip()) + return resources, activities + +def _parse_part_b(lines) -> list[AlternativeSubgraph]: + """ + Part (b) of ASLIB instance results in alternative subgraphs. + """ pct_flex, pct_nested, pct_linked = map(float, next(lines).split()) num_subgraphs = int(next(lines)) total_branches = 1 # first branch is always the dummy branch @@ -63,138 +70,128 @@ def parse_aslib(loc: str | Path) -> list[AlternativeSubgraph]: for idx in branch_idcs: branches[idx - 1].append(activity) + # Return the alternative subgraphs, with the first subgraph containing the + # fixed activities (an activity belongs to node branch 0 if it is fixed). result = [AlternativeSubgraph([branches[0]])] result += [ AlternativeSubgraph([branches[idx] for idx in branch_idcs]) for branch_idcs in subgraphs ] + return result -def to_networkx_graph(instance: ProjectInstance) -> nx.DiGraph: - """ - Converts a ProjectInstance to a networkX DiGraph. +def parse_aslib(loc: str | Path) -> ProjectInstance: """ - G = nx.DiGraph() - - for idx, activity in enumerate(instance.activities): - G.add_node(idx) - for succ in activity.successors: - G.add_edge(idx, succ) + Parses an ASLIB-formatted instance from a file. This format is used for + RCPSP instances with alternative subgraphs. - return G + Note + ---- + This function parses files that combine both "a" and "b" part files from + the ASLIB instance. You have to manually create such instances first! + Parameters + ---------- + loc + The location of the instance. -def plot_graph(G: nx.DiGraph, subgraphs: list[AlternativeSubgraph]): - """ - Plot the graph using networkx with multipartite layout and coloring - nodes based on branch membership. + Returns + ------- + ProjectInstance + The parsed project instance. """ + with open(loc, "r") as fh: + lines = iter(line.strip() for line in fh.readlines() if line.strip()) - assert nx.is_directed_acyclic_graph(G) - - for layer, nodes in enumerate(nx.topological_generations(G)): - for node in nodes: - G.nodes[node]["layer"] = layer + resources, activities = _parse_part_a(lines) + subgraphs = _parse_part_b(lines) - pos = nx.multipartite_layout(G, subset_key="layer") - colors = color_nodes(G, subgraphs) + # With the already parsed activities and alternative subgraph data, + # we add optional and selections groups data to the activities. + activities = _make_optional_activities(activities, subgraphs) - fig, ax = plt.subplots() - nx.draw_networkx( - G, - pos=pos, - ax=ax, - node_color=colors, - cmap=plt.cm.get_cmap("tab10"), - ) - ax.set_title("DAG layout in topological order") - fig.tight_layout() - plt.show() + project = Project(list(range(len(activities)))) + return ProjectInstance(resources, activities, [project]) -def color_nodes(G, graphs): - """ - Assign colors to nodes based on their branch membership. +class DiGraph: """ - colors = [] - branches = [branch for graph in graphs for branch in graph.branches] - for node in G.nodes: - for idx, branch in enumerate(branches): - if node in branch: - colors.append(idx) - break - else: - colors.append(-1) - return colors - - -def write_rcpsp_ps(loc, instance, tasks): + Simple directed graph implementation to replace networkx.DiGraph. """ - Writes instance in "van der Beek (2024)" format. - """ - with open(loc, "w") as fh: - - def write(*args): - fh.write(" ".join(map(str, args)) + "\n") - - write(instance.num_activities, instance.num_resources, 0) - write(*[res.capacity for res in instance.resources]) - fixed = [idx for idx, task in enumerate(tasks) if not task.optional] - write(*fixed) - - for task in tasks: - write() - write(*([task.duration] + task.demands)) - - groups_line = [len(task.groups)] - for group in task.groups: - groups_line.append(len(group)) - groups_line.extend(group) - - write(*groups_line) - write(len(task.successors), *task.successors) - - -@dataclass -class VDBTask: - duration: int - demands: list[int] - successors: list[int] - optional: bool - groups: list[list[int]] - - -def subgraphs2vdbtasks( - instance: ProjectInstance, subgraphs: list[AlternativeSubgraph] -) -> list[VDBTask]: + def __init__(self): + self.adj: dict[int, list[int]] = defaultdict(list) + self.nodes = set() + + def add_node(self, node: int): + self.nodes.add(node) + + def add_edge(self, u: int, v: int): + self.adj[u].append(v) + self.nodes.add(u) + self.nodes.add(v) + + def topological_sort(self) -> list[int]: + """ + Returns a topological ordering of the graph's nodes. + """ + in_degree = {node: 0 for node in self.nodes} + for u in self.adj: + for v in self.adj[u]: + in_degree[v] += 1 + + queue = deque(node for node in self.nodes if in_degree[node] == 0) + order = [] + while queue: + node = queue.popleft() + order.append(node) + for neighbor in self.adj[node]: + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + return order + + @classmethod + def from_activities(cls, activities: list[Activity]): + G = cls() + for idx, activity in enumerate(activities): + G.add_node(idx) + for succ in activity.successors: + G.add_edge(idx, succ) + + return G + + +def _make_optional_activities( + activities: list[Activity], + subgraphs: list[AlternativeSubgraph], +) -> list[Activity]: """ - Converts the RCPSP-AS instance to a list of tasks for VanDerBeek-format. + Adds optional and selection group data to activities based on alternative + subgraph data. Because the activity graphs are directed acylcic, we can + transform subgraphs into selection groups. """ - G = to_networkx_graph(instance) - top_gen = nx.topological_generations(G) - order = [node for nodes in top_gen for node in nodes] - - fixed_graphs, alternative_graphs = subgraphs[0], subgraphs[1:] - fixed_activities = fixed_graphs.branches[0] + G = DiGraph.from_activities(activities) + order = G.topological_sort() - all_branching = [] # all branching activities + is_fixed = subgraphs[0].branches[0] # first subgraph contains fixed nodes + alternatives = subgraphs[1:] + all_branching = [] # idcs of branching activities groups = defaultdict(list) - for graph in alternative_graphs: + for subgraph in alternatives: # The branching activities are the lowest-indexed activities in each - # branch. This works because we have a DAG: the lowest-indexed activity - # is guaranteed to be the first activity in the branch. (TODO verify.) - branching = [min(b, key=order.index) for b in graph.branches] - - # The branching arcs are the arcs that go from the principal activity - # to the branching activities. We can find the principal activity by - # finding the sole activity that goes to the branching activities. - branching_arcs = [(u, v) for u, v in G.edges if v in branching] - candidates = {u for (u, _) in branching_arcs} - assert len(candidates) == 1 # should be only 1 principal activity - principal = candidates.pop() + # branch. This works because we have a directed acyclic graph. + branching = [min(b, key=order.index) for b in subgraph.branches] + arcs = [(u, v) for u in G.adj for v in G.adj[u] if v in branching] + + # The principal activity is the sole activity that goes to the + # branching activities. + nodes = {u for (u, _) in arcs} + assert len(nodes) == 1 # should be only 1 principal activity + principal = nodes.pop() all_branching.extend(branching) groups[principal].append(branching) @@ -202,39 +199,25 @@ def subgraphs2vdbtasks( # For all remaining edges, we add another unit selection group if # v is not a branching activity. Edges with v as a branching activity # are already covered by the groups above. - for u, v in G.edges: - if v not in all_branching: - groups[u].append([v]) - - tasks = [] - for idx, activity in enumerate(instance.activities): - duration = activity.modes[0].duration - demands = activity.modes[0].demands - successors = activity.successors - task = VDBTask( - duration, - demands, - successors, - idx not in fixed_activities, - groups[idx], + for u in G.adj: + for v in G.adj[u]: + if v not in all_branching: + groups[u].append([v]) + + # Create new activities with optional and selection group data. + new = [] + for idx, activity in enumerate(activities): + activity = Activity( + modes=activity.modes, + successors=activity.successors, + optional=idx not in is_fixed, + selection_groups=groups[idx], ) - tasks.append(task) - - for task in tasks: - # In RCPSP-AS, all timing successors are also selection successors. - select_succ = [succ for group in task.groups for succ in group] - assert sorted(select_succ) == sorted(task.successors) - - return tasks - + new.append(activity) -if __name__ == "__main__": - for instance_idx in range(1010): - instance_loc = f"tmp/ASLIB/ASLIB0/aslib0_{instance_idx}a.RCP" - instance = parse(instance_loc, instance_format="patterson") - subgraphs = parse_aslib(instance_loc.replace("a.RCP", "b.RCP")) - tasks = subgraphs2vdbtasks(instance, subgraphs) + for activity in new: + # Check: In RCPSP-AS, timing successors are also selection successors. + select_succ = list(chain(*activity.selection_groups)) + assert sorted(select_succ) == sorted(activity.successors) - loc = f"tmp/rcpsp-ps/instances/ASLIB0/aslib0_{instance_idx}a.txt" - write_rcpsp_ps(loc, instance, tasks) - print(instance_idx) + return new diff --git a/tests/data/aslib0_0.rcp b/tests/data/aslib0_0.rcp new file mode 100644 index 0000000..d90416f --- /dev/null +++ b/tests/data/aslib0_0.rcp @@ -0,0 +1,251 @@ +122 5 +10 10 10 10 10 + +0 0 0 0 0 0 5 2 14 26 38 50 +0 0 0 0 0 0 6 3 4 5 6 7 8 +1 0 0 1 0 0 2 10 9 +1 0 0 5 0 0 2 10 9 +7 0 0 4 0 0 2 10 9 +1 0 0 4 0 0 2 10 9 +5 0 0 3 0 0 2 10 9 +6 0 0 1 0 0 1 9 +3 0 0 1 0 0 2 12 11 +8 0 0 2 0 0 2 12 11 +3 0 0 2 0 0 1 13 +9 0 0 2 0 0 1 13 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 6 15 16 18 19 20 24 +6 0 0 0 0 1 2 23 22 +5 0 0 0 0 3 1 17 +9 0 0 0 0 2 1 21 +5 0 0 0 0 2 1 21 +2 0 0 0 0 5 1 21 +9 0 0 0 0 2 1 21 +1 0 0 0 0 2 1 25 +4 0 0 0 0 2 1 25 +1 0 0 0 0 4 1 25 +1 0 0 0 0 2 1 25 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 6 27 28 29 30 32 36 +10 0 0 0 1 0 3 35 34 33 +2 0 0 0 2 0 2 34 31 +8 0 0 0 2 0 2 34 33 +1 0 0 0 1 0 1 31 +1 0 0 0 4 0 1 33 +3 0 0 0 5 0 1 33 +4 0 0 0 4 0 1 37 +2 0 0 0 1 0 1 37 +8 0 0 0 3 0 1 37 +9 0 0 0 2 0 1 37 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 3 39 40 43 +4 0 0 0 1 0 3 48 42 41 +8 0 0 0 3 0 3 47 44 41 +5 0 0 0 3 0 2 46 45 +5 0 0 0 3 0 2 47 46 +10 0 0 0 1 0 1 44 +6 0 0 0 2 0 1 45 +2 0 0 0 4 0 1 49 +2 0 0 0 2 0 1 49 +3 0 0 0 2 0 1 49 +10 0 0 0 4 0 1 49 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 4 51 52 53 56 +5 1 0 0 0 0 4 60 59 58 54 +10 2 0 0 0 0 4 59 58 55 54 +2 2 0 0 0 0 3 60 59 57 +9 4 0 0 0 0 1 57 +2 2 0 0 0 0 1 57 +6 1 0 0 0 0 1 58 +1 3 0 0 0 0 1 61 +4 1 0 0 0 0 1 61 +7 4 0 0 0 0 1 61 +10 5 0 0 0 0 1 61 +0 0 0 0 0 0 1 62 +0 0 0 0 0 0 2 63 75 +0 0 0 0 0 0 3 64 65 69 +5 0 0 1 0 0 3 68 67 66 +6 0 0 2 0 0 3 68 67 66 +5 0 0 2 0 0 4 73 72 71 70 +3 0 0 3 0 0 2 72 70 +9 0 0 5 0 0 2 71 70 +1 0 0 3 0 0 2 71 70 +4 0 0 4 0 0 1 74 +5 0 0 2 0 0 1 74 +3 0 0 1 0 0 1 74 +10 0 0 2 0 0 1 74 +0 0 0 0 0 0 1 87 +0 0 0 0 0 0 6 76 78 80 83 84 85 +6 0 0 0 0 1 1 77 +3 0 0 0 0 3 2 82 81 +7 0 0 0 0 2 1 79 +9 0 0 0 0 1 1 81 +6 0 0 0 0 5 1 81 +4 0 0 0 0 1 1 86 +10 0 0 0 0 3 1 86 +7 0 0 0 0 1 1 86 +2 0 0 0 0 3 1 86 +8 0 0 0 0 5 1 86 +0 0 0 0 0 0 1 87 +0 0 0 0 0 0 5 88 89 90 91 92 +1 0 0 0 1 0 4 97 96 95 93 +10 0 0 0 3 0 3 97 95 94 +1 0 0 0 3 0 3 96 95 94 +4 0 0 0 3 0 2 96 93 +2 0 0 0 3 0 2 95 93 +10 0 0 0 1 0 1 94 +3 0 0 0 4 0 1 98 +10 0 0 0 2 0 1 98 +7 0 0 0 3 0 1 98 +4 0 0 0 2 0 1 98 +0 0 0 0 0 0 1 99 +0 0 0 0 0 0 7 100 102 103 106 107 108 109 +3 0 0 0 1 0 1 101 +7 0 0 0 2 0 2 105 104 +9 0 0 0 4 0 1 104 +8 0 0 0 2 0 1 104 +8 0 0 0 2 0 1 110 +1 0 0 0 3 0 1 110 +7 0 0 0 3 0 1 110 +6 0 0 0 3 0 1 110 +10 0 0 0 2 0 1 110 +2 0 0 0 3 0 1 110 +0 0 0 0 0 0 1 111 +0 0 0 0 0 0 3 112 113 114 +10 0 0 0 0 1 4 121 120 118 115 +3 0 0 0 0 3 3 120 119 115 +6 0 0 0 0 3 3 119 117 116 +9 0 0 0 0 2 1 117 +5 0 0 0 0 2 1 118 +8 0 0 0 0 3 1 122 +8 0 0 0 0 3 1 122 +7 0 0 0 0 3 1 122 +8 0 0 0 0 3 1 122 +2 0 0 0 0 2 1 122 +0 0 0 0 0 0 0 +0.250000 0.000000 0.000000 +2 +5 2 3 4 5 6 +2 7 8 +1 1 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 2 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 3 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 4 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 5 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 6 +1 1 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 7 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 8 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 diff --git a/tests/test_parse_aslib.py b/tests/test_parse_aslib.py new file mode 100644 index 0000000..409806f --- /dev/null +++ b/tests/test_parse_aslib.py @@ -0,0 +1,42 @@ +from numpy.testing import assert_equal + +from psplib import parse_aslib + +from .utils import relative + + +def test_aslib0_0(): + """ + Tests that the instance ``aslib0_0.rcp`` is correctly parsed. + """ + instance = parse_aslib(relative("data/aslib0_0.rcp")) + assert_equal(instance.num_resources, 5) + + capacities = [res.capacity for res in instance.resources] + renewables = [res.renewable for res in instance.resources] + + assert_equal(capacities, [10, 10, 10, 10, 10]) + assert_equal(renewables, [True, True, True, True, True]) + + assert_equal(instance.num_activities, 122) + + activity = instance.activities[0] # source + assert_equal(activity.successors, [1, 13, 25, 37, 49]) + assert_equal(activity.optional, False) # source always present + assert_equal(activity.selection_groups, [[1, 13, 25, 37, 49]]) + + assert_equal(activity.num_modes, 1) + assert_equal(activity.modes[0].demands, [0, 0, 0, 0, 0]) + assert_equal(activity.modes[0].duration, 0) + + activity = instance.activities[2] + assert_equal(activity.successors, [9, 8]) + assert_equal(activity.optional, True) + assert_equal(activity.selection_groups, [[9], [8]]) + + assert_equal(activity.num_modes, 1) + assert_equal(activity.modes[0].demands, [0, 0, 1, 0, 0]) + assert_equal(activity.modes[0].duration, 1) + + assert_equal(instance.num_projects, 1) + assert_equal(instance.projects[0].num_activities, 122) From 3d1d972a87357ee6b4f68272319b2a5f9a026544 Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Wed, 5 Feb 2025 17:18:28 +0100 Subject: [PATCH 04/10] Revert to Py3.9 style --- psplib/parse_aslib.py | 3 ++- psplib/parse_rcpsp_ps.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/psplib/parse_aslib.py b/psplib/parse_aslib.py index e26c91f..73f0ada 100644 --- a/psplib/parse_aslib.py +++ b/psplib/parse_aslib.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from itertools import chain from pathlib import Path +from typing import Union from .ProjectInstance import ( Activity, @@ -81,7 +82,7 @@ def _parse_part_b(lines) -> list[AlternativeSubgraph]: return result -def parse_aslib(loc: str | Path) -> ProjectInstance: +def parse_aslib(loc: Union[str, Path]) -> ProjectInstance: """ Parses an ASLIB-formatted instance from a file. This format is used for RCPSP instances with alternative subgraphs. diff --git a/psplib/parse_rcpsp_ps.py b/psplib/parse_rcpsp_ps.py index dc3a5dc..c7411cb 100644 --- a/psplib/parse_rcpsp_ps.py +++ b/psplib/parse_rcpsp_ps.py @@ -1,9 +1,10 @@ from pathlib import Path +from typing import Union from .ProjectInstance import Activity, Mode, Project, ProjectInstance, Resource -def parse_rcpsp_ps(instance_loc: str | Path) -> ProjectInstance: +def parse_rcpsp_ps(instance_loc: Union[str, Path]) -> ProjectInstance: """ Parses a RCPSP-PS formatted instance from Van der Beek et al. (2024). """ From 2b518c5511e8a636a814538bc7aba19ed7eb47bd Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Wed, 5 Feb 2025 17:23:06 +0100 Subject: [PATCH 05/10] Introduce Optional --- psplib/ProjectInstance.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/psplib/ProjectInstance.py b/psplib/ProjectInstance.py index dabe459..5fc83cc 100644 --- a/psplib/ProjectInstance.py +++ b/psplib/ProjectInstance.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from typing import Optional @dataclass @@ -64,7 +65,7 @@ class Activity: modes: list[Mode] successors: list[int] - delays: list[int] | None = None + delays: Optional[list[int]] = None selection_groups: list[list[int]] = field(default_factory=list) optional: bool = False name: str = "" From 204722e30d313097a85ef8a187d5fdc0621157fe Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Thu, 20 Feb 2025 09:09:18 +0100 Subject: [PATCH 06/10] Update README --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 45654a9..f0ed132 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ This library implements parsers for various project scheduling benchmark instances, including: - Resource-Constrained Project Scheduling Problem (RCPSP) -- Multi-Mode Resource-Constrained Project Scheduling Problem (MMRCPSP) -- Resource-Constrained Project Scheduling Problem with Minimal and Maximal Time Lags (RCPSP/max) +- Multi-Mode RCPSP (MMRCPSP) +- RCPSP with Minimal and Maximal Time Lags (RCPSP/max) - Resource-Constrained Multi Project Scheduling Problem (RCMPSP) +- RCPSP with flexible project structure (RCPSP-PS) and RCPSP with alternative subgraphs (RCPSP-AS) `psplib` has no dependencies and can be installed in the usual way: @@ -47,6 +48,11 @@ To parse a specific instance format, set the `instance_format` argument in `pars 2. `patterson`: The **Patterson format**: used for RCPSP instances, mostly used by the [OR&S](https://www.projectmanagement.ugent.be/research/data) library. See [this](http://www.p2engine.com/p2reader/patterson_format) website for more details. 3. `rcpsp_max`: The **RCPSP/max format** is used for RCPSP/max instances from [TU Clausthal](https://www.wiwi.tu-clausthal.de/en/ueber-uns/abteilungen/betriebswirtschaftslehre-insbesondere-produktion-und-logistik/research/research-areas/project-generator-progen/max-and-psp/max-library/single-mode-project-duration-problem-rcpsp/max). 4. `mplib`: The **MPLIB format** is used for RCMPSP instances from the [MPLIB](https://www.projectmanagement.ugent.be/research/data) library. +5. `rcpsp_ps`: The **RCPSP-PS format** is the format used by [Van der Beek et al. (2024)](https://www.sciencedirect.com/science/article/pii/S0377221724008269). +Specifically, we included an extra line that defines for each task whether it is optional or not. +6. `aslib`: The **ASLIB format** is the format used by RCPSP-AS instances from the ASLIB instance set at [OR&S project database](https://www.projectmanagement.ugent.be/research/data). +ASLIB consist of three different parts (a, b, c). +To use this parser, you have to merge parts (a) and (b) into a single file - part (c) is not parsed. ## Instance databases From 87fa64fecef9c91b8846b76d2975e10ea36fdf27 Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Thu, 20 Feb 2025 09:10:07 +0100 Subject: [PATCH 07/10] Add README to instance formats --- formats/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 formats/README.md diff --git a/formats/README.md b/formats/README.md new file mode 100644 index 0000000..2ea616b --- /dev/null +++ b/formats/README.md @@ -0,0 +1,3 @@ +# Instance formats + +This folder contains instructions for each instance format. From e2a062a8168826a09d9ad00b34fde9e5188953f3 Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Thu, 20 Feb 2025 09:11:24 +0100 Subject: [PATCH 08/10] Change order optional/selection groups --- psplib/ProjectInstance.py | 8 ++++---- psplib/parse_rcpsp_ps.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/psplib/ProjectInstance.py b/psplib/ProjectInstance.py index 5fc83cc..d483298 100644 --- a/psplib/ProjectInstance.py +++ b/psplib/ProjectInstance.py @@ -52,12 +52,12 @@ class Activity: the length of this list must be equal to the length of `successors`. Delays are used for RCPSP/max instances, where the precedence relationship is defined as ``start(pred) + delay <= start(succ)``. + optional + Whether this activity is optional or not. Default ``False``. selection_groups The selection groups of this activity. If the current activity is scheduled, then for each group, exactly one activity must be scheduled. - This is used for RCPSP-PS instances. Default empty list. - optional - Whether this activity is optional or not. Default ``False``. + This is used for RCPSP-PS instances. Default is an empty list. name Optional name of the activity to identify this activity. This is helpful to map this activity back to the original problem instance. @@ -66,8 +66,8 @@ class Activity: modes: list[Mode] successors: list[int] delays: Optional[list[int]] = None - selection_groups: list[list[int]] = field(default_factory=list) optional: bool = False + selection_groups: list[list[int]] = field(default_factory=list) name: str = "" def __post_init__(self): diff --git a/psplib/parse_rcpsp_ps.py b/psplib/parse_rcpsp_ps.py index c7411cb..fd276c6 100644 --- a/psplib/parse_rcpsp_ps.py +++ b/psplib/parse_rcpsp_ps.py @@ -35,8 +35,8 @@ def parse_rcpsp_ps(instance_loc: Union[str, Path]) -> ProjectInstance: Activity( [Mode(duration, demands)], successors, - selection_groups=groups, optional=idx > 0, # source activity is not optional + selection_groups=groups, ) ) From 01abce333e9956a953d61496180d0f003047ba4c Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Thu, 20 Feb 2025 09:17:14 +0100 Subject: [PATCH 09/10] Example notebook --- example.ipynb | 162 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 126 insertions(+), 36 deletions(-) diff --git a/example.ipynb b/example.ipynb index 64db24f..2eec68c 100644 --- a/example.ipynb +++ b/example.ipynb @@ -19,7 +19,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 1, "id": "4fec4d5b-df0f-48c4-9e55-602203a89176", "metadata": {}, "outputs": [], @@ -45,7 +45,7 @@ }, { "cell_type": "code", - "execution_count": 53, + "execution_count": 2, "id": "0530bec2-580c-4845-a1f7-8f94d69a5f40", "metadata": {}, "outputs": [], @@ -63,7 +63,7 @@ }, { "cell_type": "code", - "execution_count": 54, + "execution_count": 3, "id": "bfbca94a-720a-4873-b1b7-3c0a52d2a5b0", "metadata": {}, "outputs": [ @@ -73,7 +73,7 @@ "(4, 32)" ] }, - "execution_count": 54, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -92,7 +92,7 @@ }, { "cell_type": "code", - "execution_count": 55, + "execution_count": 4, "id": "e2f6acd6-f529-4833-b479-e00550584820", "metadata": {}, "outputs": [ @@ -105,7 +105,7 @@ " Resource(capacity=12, renewable=True)]" ] }, - "execution_count": 55, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -124,20 +124,20 @@ }, { "cell_type": "code", - "execution_count": 56, + "execution_count": 5, "id": "8a1235ac-d44f-4d01-9081-dfb7778c2fc7", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[1, 2, 3], delays=None, name=''),\n", - " Activity(modes=[Mode(duration=8, demands=[4, 0, 0, 0])], successors=[5, 10, 14], delays=None, name=''),\n", - " Activity(modes=[Mode(duration=4, demands=[10, 0, 0, 0])], successors=[6, 7, 12], delays=None, name=''),\n", - " Activity(modes=[Mode(duration=6, demands=[0, 0, 0, 3])], successors=[4, 8, 9], delays=None, name='')]" + "[Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[1, 2, 3], delays=None, optional=False, selection_groups=[], name=''),\n", + " Activity(modes=[Mode(duration=8, demands=[4, 0, 0, 0])], successors=[5, 10, 14], delays=None, optional=False, selection_groups=[], name=''),\n", + " Activity(modes=[Mode(duration=4, demands=[10, 0, 0, 0])], successors=[6, 7, 12], delays=None, optional=False, selection_groups=[], name=''),\n", + " Activity(modes=[Mode(duration=6, demands=[0, 0, 0, 3])], successors=[4, 8, 9], delays=None, optional=False, selection_groups=[], name='')]" ] }, - "execution_count": 56, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -156,7 +156,7 @@ }, { "cell_type": "code", - "execution_count": 57, + "execution_count": 6, "id": "33dec608-c15c-4b7b-894d-0a6f67d8dafc", "metadata": {}, "outputs": [ @@ -166,7 +166,7 @@ "[Mode(duration=0, demands=[0, 0, 0, 0])]" ] }, - "execution_count": 57, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -194,7 +194,7 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": 7, "id": "7c44dee1-a7d8-4c5f-9d5c-9dc119bb597d", "metadata": {}, "outputs": [], @@ -220,7 +220,7 @@ }, { "cell_type": "code", - "execution_count": 60, + "execution_count": 8, "id": "2a0012ed-f019-4aaf-85b3-93241d380401", "metadata": {}, "outputs": [], @@ -238,20 +238,20 @@ }, { "cell_type": "code", - "execution_count": 61, + "execution_count": 9, "id": "6cdb8e24-587f-4b4b-b836-d808a3ecbe3e", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0, 0])], successors=[3, 2, 1, 8], delays=[0, 0, 0, 0], name=''),\n", - " Activity(modes=[Mode(duration=2, demands=[5, 7, 8, 4, 6])], successors=[10], delays=[2], name=''),\n", - " Activity(modes=[Mode(duration=9, demands=[10, 8, 0, 8, 10])], successors=[4, 11, 7], delays=[5, 9, 0], name=''),\n", - " Activity(modes=[Mode(duration=6, demands=[9, 9, 0, 4, 5])], successors=[9], delays=[3], name='')]" + "[Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0, 0])], successors=[3, 2, 1, 8], delays=[0, 0, 0, 0], optional=False, selection_groups=[], name=''),\n", + " Activity(modes=[Mode(duration=2, demands=[5, 7, 8, 4, 6])], successors=[10], delays=[2], optional=False, selection_groups=[], name=''),\n", + " Activity(modes=[Mode(duration=9, demands=[10, 8, 0, 8, 10])], successors=[4, 11, 7], delays=[5, 9, 0], optional=False, selection_groups=[], name=''),\n", + " Activity(modes=[Mode(duration=6, demands=[9, 9, 0, 4, 5])], successors=[9], delays=[3], optional=False, selection_groups=[], name='')]" ] }, - "execution_count": 61, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -278,7 +278,7 @@ }, { "cell_type": "code", - "execution_count": 63, + "execution_count": 10, "id": "3280aeae-0bcd-4faa-8649-6a8b64db4e5e", "metadata": {}, "outputs": [], @@ -288,7 +288,7 @@ }, { "cell_type": "code", - "execution_count": 64, + "execution_count": 11, "id": "f3d65f88-1840-4665-abcb-3d1fd3ae46da", "metadata": {}, "outputs": [ @@ -298,7 +298,7 @@ "(4, 372, 6)" ] }, - "execution_count": 64, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -317,7 +317,7 @@ }, { "cell_type": "code", - "execution_count": 68, + "execution_count": 12, "id": "67e3f2ad-8aed-47fe-9a43-cbe7159a585d", "metadata": {}, "outputs": [ @@ -328,7 +328,7 @@ " Project(activities=[62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123], release_date=0)]" ] }, - "execution_count": 68, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -376,7 +376,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 13, "id": "c7b74459-6107-4e88-a729-7f0cc7d7f76f", "metadata": {}, "outputs": [], @@ -386,7 +386,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 14, "id": "bebe5e60-c84a-45c4-a9ef-9d7f34eed561", "metadata": {}, "outputs": [ @@ -396,7 +396,7 @@ "(4, 136)" ] }, - "execution_count": 7, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -407,17 +407,17 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 15, "id": "990c6936-cbf2-47c4-823d-35fbdc2c240f", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[1, 2], delays=None, selection_groups=[[1, 2]], optional=False, name='')" + "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[1, 2], delays=None, optional=False, selection_groups=[[1, 2]], name='')" ] }, - "execution_count": 13, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -439,17 +439,17 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 16, "id": "1cb5b8c1-a6f3-4297-a327-fb2b4f05a927", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[3, 4], delays=None, selection_groups=[[3], [4]], optional=True, name='')" + "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0])], successors=[3, 4], delays=None, optional=True, selection_groups=[[3], [4]], name='')" ] }, - "execution_count": 14, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -467,6 +467,96 @@ "This activity has two selection groups, one with activity 3, and the other with activity 4.\n", "If this activity is scheduled, then both activity 3 and activity must be scheduled." ] + }, + { + "cell_type": "markdown", + "id": "01d7ae4f-58c3-445b-899c-2919a0fd3a79", + "metadata": {}, + "source": [ + "### ASLIB\n", + "The \"ASLIB\" format is the format used for RCPSP with alternative subgraph (RCPSP-AS) instances at https://www.projectmanagement.ugent.be/research/project_scheduling/rcpspas.\n", + "\n", + "Like the RCPSP-PS, the RCPSP-AS also has optional tasks and selection groups, but they use slightly different concepts (branches and subgraphs). \n", + "\n", + "**Important**: RCPSP-AS instances from the ASLIB instance set contain multiple file parts (a, b, c).\n", + "Our library parses _merged part (a) and (b)_ files, meaning that you have to manually concatenate files first.\n", + "See the instance file for details." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "e9a53c91-5102-4a7a-8768-24fa645370c2", + "metadata": {}, + "outputs": [], + "source": [ + "instance = parse(\"data/aslib0_0.rcp\", instance_format=\"aslib\")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "26e640fe-bac2-41dc-8761-f645d144dc74", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(5, 122)" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(instance.num_resources, instance.num_activities)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "21d245eb-c3b4-4717-9597-f1850e3f9689", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0, 0])], successors=[1, 13, 25, 37, 49], delays=None, optional=False, selection_groups=[[1, 13, 25, 37, 49]], name='')" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "activity = instance.activities[0] # source\n", + "activity" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "889d69b0-b492-4423-9d9d-64bac9875141", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Activity(modes=[Mode(duration=0, demands=[0, 0, 0, 0, 0])], successors=[2, 3, 4, 5, 6, 7], delays=None, optional=True, selection_groups=[[2], [3], [4], [5], [6], [7]], name='')" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "activity = instance.activities[1]\n", + "activity" + ] } ], "metadata": { From f14223979eb450875d03dccb78b86ca55fcd9ddc Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Thu, 20 Feb 2025 09:21:16 +0100 Subject: [PATCH 10/10] Explain ASLIB format --- formats/aslib.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 formats/aslib.md diff --git a/formats/aslib.md b/formats/aslib.md new file mode 100644 index 0000000..90ee97a --- /dev/null +++ b/formats/aslib.md @@ -0,0 +1,5 @@ +# ASLIB + +ASLIB files contain two parts (A) and (B). +See https://www.projectmanagement.ugent.be/research/project_scheduling/rcpspas for details. +The `psplib` library expects a single file which includes the merged parts of (A) and (B).