Wauplin HF Staff commited on
Commit
e9315b2
·
verified ·
0 Parent(s):

initial commit

Browse files
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+
204
+ # Ruff stuff:
205
+ .ruff_cache/
206
+
207
+ # PyPI configuration file
208
+ .pypirc
209
+
210
+ # Marimo
211
+ marimo/_static/
212
+ marimo/_lsp/
213
+ __marimo__/
214
+
215
+ # Streamlit
216
+ .streamlit/secrets.toml
217
+
218
+ charts/
219
+ !charts/gantt_fifo_policy.png
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Copy project files
6
+ COPY pyproject.toml ./
7
+ COPY src/ ./src/
8
+ COPY app.py ./
9
+
10
+ # Install the project
11
+ RUN pip install --no-cache-dir -e .
12
+
13
+ # Expose port 7860
14
+ EXPOSE 7860
15
+
16
+ # Run uvicorn directly
17
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
18
+
README.md ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: JSSP OpenEnv
3
+ emoji: ⏰
4
+ colorFrom: green
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ <p align="center">
11
+ <img src="assets/jssp_openenv.png" alt="jssp_openenv" width="400">
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://huggingface.co/spaces/Wauplin/jssp_openenv" style="font-size: 1.2em;">Try it live on Hugging Face Spaces</a>
16
+ </p>
17
+
18
+ ## Job shop scheduling problem (JSSP)
19
+
20
+ The [Job Shop Scheduling Problem](https://en.wikipedia.org/wiki/Job-shop_scheduling) (JSSP) is a classic optimization problem in operations research. Given a set of jobs, each consisting of multiple operations that must be performed in a specific sequence, and a set of machines, the goal is to schedule the operations on machines to minimize the total completion time (makespan).
21
+
22
+ **Key constraints:**
23
+ - Each job consists of a sequence of operations that must be completed in order
24
+ - Each operation requires a specific machine for a given duration
25
+ - Each machine can process only one operation at a time
26
+ - Once started, an operation cannot be interrupted
27
+
28
+ This implementation uses the OpenEnv framework to create a reinforcement learning environment where an agent (policy) learns to make scheduling decisions at each time step.
29
+
30
+ > !TIP
31
+ > For now, we only implement and run the FT06 problem. It is a well-known problem in the literature with a known optimal solution.
32
+ > Goal for training is to run arbitrary random environments.
33
+
34
+ ## OpenEnv
35
+
36
+ [OpenEnv](https://github.com/meta-pytorch/OpenEnv) is a framework from Meta PyTorch and Hugging Face for building reinforcement learning environments. It provides:
37
+
38
+ - A standardized interface for environments with `Action` and `Observation` models
39
+ - A web-based interface for interactive exploration of environments
40
+ - A client-server architecture for distributed training and evaluation
41
+ - Integration with LLM-based policies for solving complex problems
42
+
43
+ This project implements a JSSP environment using OpenEnv, allowing you to:
44
+ - Interact with the environment through a web interface
45
+ - Test different scheduling policies (FIFO, Max-Min, LLM-based)
46
+ - Train reinforcement learning agents to solve JSSP instances
47
+
48
+ ## Project Architecture
49
+
50
+ The project follows a client-server architecture using the OpenEnv framework:
51
+
52
+ ### Core Components
53
+
54
+ **Models** (`src/jssp_openenv/models.py`):
55
+ - `JSSPAction`: Represents scheduling actions (list of job IDs to schedule)
56
+ - `JSSPObservation`: Contains the current state (machines, ready operations, progress)
57
+
58
+ **Environment** (`src/jssp_openenv/server/jssp_environment.py`):
59
+ - `JSSPEnvironment`: The core simulation environment that:
60
+ - Manages job progress and machine states
61
+ - Validates actions and enforces constraints
62
+ - Advances simulation time using SimPy
63
+ - Returns observations and rewards
64
+
65
+ **Client** (`src/jssp_openenv/client.py`):
66
+ - `JSSPEnvClient`: HTTP client that communicates with the environment server
67
+ - Handles action serialization and observation parsing
68
+
69
+ **Policies** (`src/jssp_openenv/policy.py`):
70
+ - `JSSPEnvPolicy`: Abstract base class for scheduling policies
71
+ - `JSSPFifoPolicy`: First-In-First-Out scheduling (schedules jobs by ID order)
72
+ - `JSSPMaxMinPolicy`: Max-Min scheduling (prioritizes longest operations)
73
+ - `JSSPLLMPolicy`: LLM-based scheduling using OpenAI-compatible APIs
74
+
75
+ **Solver** (`src/jssp_openenv/solver.py`):
76
+ - `solve_jssp()`: Orchestrates the solving process by:
77
+ - Resetting the environment
78
+ - Iteratively applying policy actions
79
+ - Tracking scheduled events for visualization
80
+ - Returning makespan and event history
81
+
82
+ **Visualization** (`src/jssp_openenv/gantt.py`):
83
+ - Generates Gantt charts showing the schedule timeline
84
+
85
+ ## How to use
86
+
87
+ ### Install
88
+
89
+ Install the package and its dependencies:
90
+
91
+ ```bash
92
+ pip install -e .
93
+ ```
94
+
95
+ For development with additional tools (pytest, ruff, etc.):
96
+
97
+ ```bash
98
+ pip install -e ".[dev]"
99
+ ```
100
+
101
+ **Note:** For LLM-based policies, you'll need to set the `HF_TOKEN` environment variable with your Hugging Face API token:
102
+
103
+ ```bash
104
+ export HF_TOKEN=your_token_here
105
+ ```
106
+
107
+ ### Run server
108
+
109
+ To play with the environment locally, run
110
+
111
+ ```
112
+ python app.py
113
+ ```
114
+
115
+ and go to http://0.0.0.0:8000/web.
116
+
117
+ ### Run policy
118
+
119
+ **FIFO policy** (always run first available job):
120
+
121
+ ```
122
+ python run.py fifo
123
+ ```
124
+
125
+ **Max-Min policy** (always run longest job first):
126
+
127
+ ```
128
+ python run.py maxmin
129
+ ```
130
+
131
+ **LLM policy** (ask an LLM to solve the problem)
132
+
133
+ ```
134
+ python run.py llm --model-id "openai/gpt-oss-20b:groq"
135
+ python run.py llm --model-id "openai/gpt-oss-120b:cerebras"
136
+ python run.py llm --model-id "Qwen/Qwen3-32B:groq"
137
+ ```
138
+
139
+ ### Check results
140
+
141
+ The solver will resolve the problem using the policy and then plot a gantt chart of the solution in the `./charts` folder.
142
+
143
+ Here is an example:
144
+
145
+ ![FIFO Policy Gantt Chart](assets/gantt_fifo_policy.png)
146
+
147
+ ## Run with docker
148
+
149
+ Build the Docker image:
150
+
151
+ ```bash
152
+ docker build -t jssp-openenv .
153
+ ```
154
+
155
+ Run the container:
156
+
157
+ ```bash
158
+ docker run -p 7860:7860 jssp-openenv
159
+ ```
160
+
161
+ The web interface will be available at http://localhost:7860/web.
162
+
163
+ ## TODO
164
+
165
+ - [ ] run on other example environments (FT10, FT20)
166
+ - [ ] run on random environments
167
+ - [ ] run multiple policies and summarize results
168
+ - [ ] trainer
app.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from openenv_core.env_server import create_web_interface_app
2
+
3
+ from jssp_openenv.examples import FT06
4
+ from jssp_openenv.models import JSSPAction, JSSPObservation
5
+ from jssp_openenv.server.jssp_environment import JSSPEnvironment
6
+
7
+ env = JSSPEnvironment(FT06)
8
+ app = create_web_interface_app(env, JSSPAction, JSSPObservation, "JSSP (FT06)")
9
+
10
+ if __name__ == "__main__":
11
+ import uvicorn
12
+
13
+ uvicorn.run(app, host="0.0.0.0", port=8000)
assets/gantt_fifo_policy.png ADDED

Git LFS Details

  • SHA256: cf2161a889451057398c87bd70aaba5eb6bb1340ce348fb7314297b99c00e269
  • Pointer size: 130 Bytes
  • Size of remote file: 52.8 kB
assets/jssp_openenv.png ADDED

Git LFS Details

  • SHA256: 6366778083a288fb0380ebc6a8562c2613d76c3da05d570d4061ff2e96f4ecb5
  • Pointer size: 131 Bytes
  • Size of remote file: 232 kB
pyproject.toml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jssp_openenv"
7
+ description = "JSSP environment for OpenEnv"
8
+ version = "0.1.0"
9
+ authors = [
10
+ { name="Lucain Pouget", email="lucain@huggingface.co" }
11
+ ]
12
+ requires-python = ">=3.10"
13
+ dependencies = [
14
+ "openenv-core>=0.1.0",
15
+ "openai", # for inference.py
16
+ "simpy", # for env simulation
17
+ "matplotlib", # for plotting
18
+ "typer", # for CLI
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest",
24
+ "ruff",
25
+ "ty",
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ package-dir = {"" = "src"}
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.ruff]
35
+ exclude = [".git", ".ruff_cache", ".venv"]
36
+ line-length = 119
37
+ # Ignored rules:
38
+ # "E501" -> line length violation
39
+ lint.ignore = ["E501"]
40
+ lint.select = ["E", "F", "I", "W"]
run.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from enum import Enum
3
+
4
+ import typer
5
+ from openai import OpenAI
6
+
7
+ from jssp_openenv.client import JSSPEnvClient
8
+ from jssp_openenv.gantt import gantt_chart
9
+ from jssp_openenv.policy import JSSPEnvPolicy, JSSPFifoPolicy, JSSPLLMPolicy, JSSPMaxMinPolicy
10
+ from jssp_openenv.solver import solve_jssp
11
+
12
+ SERVER_URL = "http://localhost:8000"
13
+ MAX_STEPS = 1000 # Maximum number of steps per instance
14
+ CHART_DIR = "charts"
15
+ os.makedirs(CHART_DIR, exist_ok=True)
16
+
17
+ cli = typer.Typer()
18
+
19
+
20
+ class PolicyName(str, Enum):
21
+ FIFO = "fifo"
22
+ LLM = "llm"
23
+ MAX_MIN = "maxmin"
24
+
25
+
26
+ @cli.command()
27
+ def solve(
28
+ policy: PolicyName = typer.Argument(help="The policy to use"),
29
+ server_url: str = typer.Option(SERVER_URL, help="The URL of the JSSP server"),
30
+ max_steps: int = typer.Option(MAX_STEPS, help="The maximum number of steps per instance"),
31
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Whether to print verbose output"),
32
+ model_id: str = typer.Option(None, "--model-id", "-m", help="The ID of the model to use"),
33
+ ):
34
+ """Solve a JSSP instance using the given policy."""
35
+ env_client = JSSPEnvClient(base_url=server_url)
36
+
37
+ policy_obj: JSSPEnvPolicy
38
+ match policy:
39
+ case PolicyName.FIFO:
40
+ policy_obj = JSSPFifoPolicy()
41
+ title = "FIFO Policy"
42
+ filename = "gantt_fifo_policy.png"
43
+
44
+ case PolicyName.LLM:
45
+ if not model_id:
46
+ raise ValueError("You must set --model-id to use the LLM policy")
47
+ api_key = os.getenv("HF_TOKEN")
48
+ if not api_key:
49
+ raise ValueError("You must set the HF_TOKEN environment variable to use the LLM policy")
50
+ client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=api_key)
51
+ policy_obj = JSSPLLMPolicy(client=client, model_id=model_id)
52
+ title = f"LLM Policy ({model_id})"
53
+ filename = f"gantt_llm_policy_{model_id.replace('/', '_').replace(':', '_').replace('-', '_').replace(' ', '_')}.png"
54
+
55
+ case PolicyName.MAX_MIN:
56
+ policy_obj = JSSPMaxMinPolicy()
57
+ title = "Max-Min Policy"
58
+ filename = "gantt_maxmin_policy.png"
59
+
60
+ makespan, scheduled_events = solve_jssp(env_client, policy_obj, max_steps, verbose)
61
+
62
+ if verbose:
63
+ print("Schedule events:")
64
+ for event in scheduled_events:
65
+ print(
66
+ f"[{event.start_time}] Scheduling job {event.job_id} on machine {event.machine_id} for {event.end_time - event.start_time} minute(s)"
67
+ )
68
+
69
+ print(f"Solved in {makespan} steps")
70
+
71
+ filepath = os.path.join(CHART_DIR, filename)
72
+ gantt_chart(scheduled_events, title=title, makespan=makespan, save_to=filepath)
73
+ print(f"Saved Gantt chart to {filepath}")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ cli()
src/jssp_openenv/__init__.py ADDED
File without changes
src/jssp_openenv/client.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from openenv_core import HTTPEnvClient, StepResult
2
+
3
+ from .models import JSSPAction, JSSPObservation, MachineObservation, ReadyOperationObservation
4
+
5
+
6
+ class JSSPEnvClient(HTTPEnvClient[JSSPAction, JSSPObservation]):
7
+ def _step_payload(self, action: JSSPAction) -> dict:
8
+ return {"job_ids": action.job_ids}
9
+
10
+ def _parse_result(self, payload: dict) -> StepResult[JSSPObservation]:
11
+ obs_data = payload["observation"]
12
+ return StepResult[JSSPObservation](
13
+ observation=JSSPObservation(
14
+ machines=[MachineObservation(**machine) for machine in obs_data.pop("machines")],
15
+ ready_operations=[
16
+ ReadyOperationObservation(**operation) for operation in obs_data.pop("ready_operations")
17
+ ],
18
+ **obs_data,
19
+ ),
20
+ reward=payload.get("reward"),
21
+ done=payload.get("done", False),
22
+ )
23
+
24
+ def _parse_state(self, payload: dict) -> dict:
25
+ return payload
src/jssp_openenv/examples.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Example instances for the JSSP environment.
3
+
4
+ Checkout https://github.com/tamy0612/JSPLIB for more instances.
5
+ """
6
+
7
+ from .models import JobT
8
+
9
+ # Fisher and Thompson 6x6 instance
10
+ # https://github.com/tamy0612/JSPLIB/blob/master/instances/ft06
11
+ # optimal solution: 55
12
+ FT06: list[JobT] = [
13
+ [(2, 1), (0, 3), (1, 6), (3, 7), (5, 3), (4, 6)],
14
+ [(1, 8), (2, 5), (4, 10), (5, 10), (0, 10), (3, 4)],
15
+ [(2, 5), (3, 4), (5, 8), (0, 9), (1, 1), (4, 7)],
16
+ [(1, 5), (0, 5), (2, 5), (3, 3), (4, 8), (5, 9)],
17
+ [(2, 9), (1, 3), (4, 5), (5, 4), (0, 3), (3, 1)],
18
+ [(1, 3), (3, 3), (5, 9), (0, 10), (4, 4), (2, 1)],
19
+ ]
20
+
21
+ # Fisher and Thompson 10x10 instance
22
+ # https://github.com/tamy0612/JSPLIB/blob/master/instances/ft10
23
+ # optimal solution: 930
24
+ FT_10: list[JobT] = [
25
+ [(0, 29), (1, 78), (2, 9), (3, 36), (4, 49), (5, 11), (6, 62), (7, 56), (8, 44), (9, 21)],
26
+ [(0, 43), (2, 90), (4, 75), (9, 11), (3, 69), (1, 28), (6, 46), (5, 46), (7, 72), (8, 30)],
27
+ [(1, 91), (0, 85), (3, 39), (2, 74), (8, 90), (5, 10), (7, 12), (6, 89), (9, 45), (4, 33)],
28
+ [(1, 81), (2, 95), (0, 71), (4, 99), (6, 9), (8, 52), (7, 85), (3, 98), (9, 22), (5, 43)],
29
+ [(2, 14), (0, 6), (1, 22), (5, 61), (3, 26), (4, 69), (8, 21), (7, 49), (9, 72), (6, 53)],
30
+ [(2, 84), (1, 2), (5, 52), (3, 95), (8, 48), (9, 72), (0, 47), (6, 65), (4, 6), (7, 25)],
31
+ [(1, 46), (0, 37), (3, 61), (2, 13), (6, 32), (5, 21), (9, 32), (8, 89), (7, 30), (4, 55)],
32
+ [(2, 31), (0, 86), (1, 46), (5, 74), (4, 32), (6, 88), (8, 19), (9, 48), (7, 36), (3, 79)],
33
+ [(0, 76), (1, 69), (3, 76), (5, 51), (2, 85), (9, 11), (6, 40), (7, 89), (4, 26), (8, 74)],
34
+ [(1, 85), (0, 13), (2, 61), (6, 7), (8, 64), (9, 76), (5, 47), (3, 52), (4, 90), (7, 45)],
35
+ ]
36
+
37
+ # Fisher and Thompson 20x5 instance
38
+ # https://github.com/tamy0612/JSPLIB/blob/master/instances/ft20
39
+ # optimal solution: 1165
40
+ FT20: list[JobT] = [
41
+ [(0, 29), (1, 9), (2, 49), (3, 62), (4, 44)],
42
+ [(0, 43), (1, 75), (3, 69), (2, 46), (4, 72)],
43
+ [(1, 91), (0, 39), (2, 90), (4, 12), (3, 45)],
44
+ [(1, 81), (0, 71), (4, 9), (2, 85), (3, 22)],
45
+ [(2, 14), (1, 22), (0, 26), (3, 21), (4, 72)],
46
+ [(2, 84), (1, 52), (4, 48), (0, 47), (3, 6)],
47
+ [(1, 46), (0, 61), (2, 32), (3, 32), (4, 30)],
48
+ [(2, 31), (1, 46), (0, 32), (3, 19), (4, 36)],
49
+ [(0, 76), (3, 76), (2, 85), (1, 40), (4, 26)],
50
+ [(1, 85), (2, 61), (0, 64), (3, 47), (4, 90)],
51
+ [(1, 78), (3, 36), (0, 11), (4, 56), (2, 21)],
52
+ [(2, 90), (0, 11), (1, 28), (3, 46), (4, 30)],
53
+ [(0, 85), (2, 74), (1, 10), (3, 89), (4, 33)],
54
+ [(2, 95), (0, 99), (1, 52), (3, 98), (4, 43)],
55
+ [(0, 6), (1, 61), (4, 69), (2, 49), (3, 53)],
56
+ [(1, 2), (0, 95), (3, 72), (4, 65), (2, 25)],
57
+ [(0, 37), (2, 13), (1, 21), (3, 89), (4, 55)],
58
+ [(0, 86), (1, 74), (4, 88), (2, 48), (3, 79)],
59
+ [(1, 69), (2, 51), (0, 11), (3, 89), (4, 74)],
60
+ [(0, 13), (1, 7), (2, 76), (3, 52), (4, 45)],
61
+ ]
src/jssp_openenv/gantt.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import matplotlib.pyplot as plt
2
+
3
+ from .models import ScheduledEvent
4
+
5
+
6
+ def gantt_chart(scheduled_events: list[ScheduledEvent], title: str, makespan: int, save_to: str) -> None:
7
+ """Generate and save a Gantt chart from schedule events using matplotlib."""
8
+ if not scheduled_events:
9
+ print("No schedule events to save.")
10
+ return
11
+
12
+ # Extract unique machines and jobs
13
+ machines = sorted(set(event.machine_id for event in scheduled_events))
14
+ jobs = sorted(set(event.job_id for event in scheduled_events))
15
+
16
+ # Create figure and axis
17
+ fig, ax = plt.subplots(figsize=(12, max(6, len(machines) * 0.8)))
18
+
19
+ # Color map for different jobs
20
+ colors = plt.cm.tab20(range(len(jobs)))
21
+ job_color_map = {job_id: colors[i % len(colors)] for i, job_id in enumerate(jobs)}
22
+
23
+ # Track which jobs have been added to legend
24
+ legend_added = set()
25
+
26
+ # Plot each schedule event as a horizontal bar
27
+ for event in scheduled_events:
28
+ machine_idx = machines.index(event.machine_id)
29
+ duration = event.end_time - event.start_time
30
+
31
+ # Only add label for legend if this job hasn't been added yet
32
+ label = f"Job {event.job_id}" if event.job_id not in legend_added else ""
33
+ if label:
34
+ legend_added.add(event.job_id)
35
+
36
+ ax.barh(
37
+ machine_idx,
38
+ duration,
39
+ left=event.start_time,
40
+ height=0.6,
41
+ color=job_color_map[event.job_id],
42
+ edgecolor="black",
43
+ linewidth=0.5,
44
+ label=label,
45
+ )
46
+
47
+ # Add job label in the middle of the bar
48
+ mid_time = event.start_time + duration / 2
49
+ ax.text(
50
+ mid_time,
51
+ machine_idx,
52
+ f"J{event.job_id}",
53
+ ha="center",
54
+ va="center",
55
+ fontsize=8,
56
+ fontweight="bold",
57
+ color="white" if sum(job_color_map[event.job_id][:3]) < 1.5 else "black",
58
+ )
59
+
60
+ # Customize the chart
61
+ ax.set_yticks(range(len(machines)))
62
+ ax.set_yticklabels([f"Machine {m}" for m in machines])
63
+ ax.set_xlabel("Time", fontsize=12)
64
+ ax.set_ylabel("Machine", fontsize=12)
65
+ ax.set_title(f"{title} (Makespan: {makespan})", fontsize=14, fontweight="bold")
66
+ ax.grid(True, axis="x", alpha=0.3, linestyle="--")
67
+
68
+ # Set x-axis limits with some padding
69
+ max_time = max(event.end_time for event in scheduled_events) if scheduled_events else 0
70
+ ax.set_xlim(0, max_time * 1.05)
71
+
72
+ # Add legend
73
+ ax.legend(loc="upper right", title="Jobs")
74
+
75
+ plt.tight_layout()
76
+ plt.savefig(save_to)
77
+ plt.close(fig)
src/jssp_openenv/models.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the JSSP Environment.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from openenv_core import Action, Observation
9
+
10
+ JobT = list[tuple[int, int]] # (machine_index, processing_time)
11
+
12
+
13
+ @dataclass(kw_only=True)
14
+ class JSSPAction(Action):
15
+ """Action for the JSSP environment."""
16
+
17
+ job_ids: list[int]
18
+
19
+ def __post_init__(self):
20
+ if isinstance(self.job_ids, str):
21
+ # For web app
22
+ self.job_ids = parse_job_ids(self.job_ids)
23
+
24
+
25
+ @dataclass(kw_only=True)
26
+ class MachineObservation:
27
+ """Observation of a single machine in the JSSP environment."""
28
+
29
+ machine_id: int
30
+ busy_until: Optional[int]
31
+ current_job_id: Optional[int]
32
+
33
+
34
+ @dataclass
35
+ class ReadyOperationObservation:
36
+ job_id: int
37
+ machine_id: int
38
+ duration: int
39
+ remaining_ops: int
40
+
41
+
42
+ @dataclass(kw_only=True)
43
+ class JSSPObservation(Observation):
44
+ """Observation from the JSSP environment - the echoed message."""
45
+
46
+ episode_id: str
47
+
48
+ step_count: int
49
+ machines: list[MachineObservation]
50
+ ready_operations: list[ReadyOperationObservation]
51
+ completed_jobs: int
52
+ total_jobs: int
53
+
54
+
55
+ def parse_job_ids(job_ids: str) -> list[int]:
56
+ """Parse job_ids from string (error out if cannot be parsed)."""
57
+ try:
58
+ return [int(job_id) for job_id in job_ids.split(",") if job_id.strip()]
59
+ except ValueError:
60
+ raise ValueError(f"Invalid job_ids: {job_ids}")
61
+
62
+
63
+ @dataclass
64
+ class ScheduledEvent:
65
+ """Represents a scheduled operation on a machine.
66
+
67
+ Used for plotting the schedule.
68
+ Not used for the environment / policy / solver.
69
+ """
70
+
71
+ job_id: int
72
+ machine_id: int
73
+ start_time: int
74
+ end_time: int
src/jssp_openenv/policy.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from abc import ABC, abstractmethod
3
+
4
+ from openai import OpenAI
5
+
6
+ from .models import JSSPAction, JSSPObservation, MachineObservation, ReadyOperationObservation
7
+
8
+
9
+ class JSSPEnvPolicy(ABC):
10
+ @abstractmethod
11
+ def act(self, observation: JSSPObservation) -> JSSPAction:
12
+ """Act based on the observation."""
13
+
14
+
15
+ class JSSPFifoPolicy(JSSPEnvPolicy):
16
+ def act(self, observation: JSSPObservation) -> JSSPAction:
17
+ """
18
+ FIFO scheduling: schedule ready operations in order of job_id.
19
+
20
+ This policy schedules operations in FIFO order (by job_id), respecting
21
+ machine availability. It only schedules operations for machines that are
22
+ currently available (not busy).
23
+ """
24
+ # Create a lookup for machine availability
25
+ machine_available = {m.machine_id: m.busy_until is None for m in observation.machines}
26
+
27
+ # Filter to only ready operations with available machines
28
+ available_ops = [op for op in observation.ready_operations if machine_available.get(op.machine_id, False)]
29
+
30
+ # Sort by job_id (FIFO: first job_id first)
31
+ available_ops.sort(key=lambda op: op.job_id)
32
+
33
+ # Track which machines we've already scheduled to avoid conflicts
34
+ scheduled_machines = set()
35
+ scheduled_job_ids = []
36
+
37
+ # Schedule jobs in FIFO order, but skip if machine is already taken
38
+ for op in available_ops:
39
+ if op.machine_id not in scheduled_machines:
40
+ scheduled_job_ids.append(op.job_id)
41
+ scheduled_machines.add(op.machine_id)
42
+
43
+ return JSSPAction(job_ids=scheduled_job_ids)
44
+
45
+
46
+ class JSSPMaxMinPolicy(JSSPEnvPolicy):
47
+ def act(self, observation: JSSPObservation) -> JSSPAction:
48
+ """
49
+ Max-Min scheduling: schedule the operation with the longest duration first.
50
+ """
51
+ # Sort operations by duration (max-min)
52
+ ops = sorted(observation.ready_operations, key=lambda op: op.duration, reverse=True)
53
+
54
+ # Track which machines we've already scheduled to avoid conflicts
55
+ scheduled_machines = set()
56
+ scheduled_job_ids = []
57
+
58
+ # Schedule jobs in max-min order, but skip if machine is already taken
59
+ for op in ops:
60
+ if op.machine_id not in scheduled_machines:
61
+ scheduled_job_ids.append(op.job_id)
62
+ scheduled_machines.add(op.machine_id)
63
+
64
+ return JSSPAction(job_ids=scheduled_job_ids)
65
+
66
+
67
+ PROMPT_TEMPLATE = """
68
+ You are solving a Job Shop Scheduling Problem (JSSP). Your goal is to minimize the total completion time (makespan) by efficiently scheduling job operations across machines.
69
+
70
+ You must optimize for minimal makespan while respecting all constraints. Each job consists of multiple operations that must be completed in sequence, and each operation requires a specific machine for a given duration.
71
+
72
+ ---
73
+
74
+ ### 🕒 Current State
75
+ **Step:** {step_count} | **Completed:** {completed_jobs}/{total_jobs}
76
+
77
+ ---
78
+
79
+ ### ⚙️ Machine Status
80
+ {machines_status}
81
+
82
+ You must check machine availability before scheduling. Machines that are busy cannot start new operations until they finish their current task.
83
+
84
+ ---
85
+
86
+ ### ✅ Ready to Schedule (NOW)
87
+ {ready_operations_list}
88
+
89
+ Each entry shows: **machine**, **duration**, and **remaining ops**.
90
+ You can only schedule operations that are ready at this step. These are operations whose previous steps in the job sequence have been completed.
91
+
92
+ ---
93
+
94
+ ### 🎯 Rules You Must Follow
95
+ 1. You must schedule only **ready** operations. Do not attempt to schedule operations that are not ready.
96
+ 2. Each machine can run **one job at a time**. You cannot schedule multiple jobs on the same machine simultaneously.
97
+ 3. You must not schedule jobs on **busy** machines (`busy_until > current step`). Check machine availability before scheduling.
98
+ 4. You may **schedule multiple** jobs on different machines in the same step, or you may choose to wait if no good scheduling opportunity exists.
99
+
100
+ ---
101
+
102
+ ### 🧩 Available Actions
103
+ {legal_actions}
104
+
105
+ These are the valid job IDs you can schedule at this step. You must choose from this list.
106
+
107
+ **Answer format:**
108
+ - To schedule jobs: `"0,2"` or `"1"` (comma-separated job IDs)
109
+ - To wait: `""` (empty string)
110
+
111
+ Respond only with the action format specified above.
112
+ """
113
+
114
+
115
+ class JSSPLLMPolicy(JSSPEnvPolicy):
116
+ """LLM-based scheduling policy using OpenAI-compatible API."""
117
+
118
+ # Job Shop Scheduling prompt template
119
+
120
+ def __init__(self, client: OpenAI, model_id: str):
121
+ """
122
+ Initialize the LLM policy.
123
+
124
+ Args:
125
+ client: OpenAI-compatible client instance
126
+ model_id: Name of the model to use
127
+ """
128
+ self.client = client
129
+ self.model_id = model_id
130
+
131
+ def act(self, observation: JSSPObservation) -> JSSPAction:
132
+ """
133
+ LLM scheduling: use an LLM to schedule the operations.
134
+
135
+ Determines legal actions (ready operations with available machines),
136
+ formats a prompt, calls the LLM, and parses the response to return
137
+ a scheduling action.
138
+ """
139
+ # Determine machine availability
140
+ machine_available = {
141
+ m.machine_id: m.busy_until is None or m.busy_until <= observation.step_count for m in observation.machines
142
+ }
143
+
144
+ # Filter ready operations to only include those with available machines
145
+ legal_job_ids = [
146
+ op.job_id for op in observation.ready_operations if machine_available.get(op.machine_id, False)
147
+ ]
148
+
149
+ # If no legal actions, return empty action (wait)
150
+ if not legal_job_ids:
151
+ return JSSPAction(job_ids=[])
152
+
153
+ # Format prompt
154
+ machines_status = self._format_machines_status(observation.machines, observation.step_count)
155
+ ready_operations_list = self._format_ready_operations(observation.ready_operations)
156
+
157
+ prompt = PROMPT_TEMPLATE.format(
158
+ step_count=observation.step_count,
159
+ completed_jobs=observation.completed_jobs,
160
+ total_jobs=observation.total_jobs,
161
+ machines_status=machines_status,
162
+ ready_operations_list=ready_operations_list,
163
+ legal_actions=legal_job_ids,
164
+ )
165
+ print(f"Step {observation.step_count}")
166
+
167
+ # Call LLM
168
+ try:
169
+ response = self.client.chat.completions.create(
170
+ model=self.model_id, messages=[{"role": "user", "content": prompt}], temperature=0.0
171
+ )
172
+ llm_output = response.choices[0].message.content or ""
173
+ print(f"LLM Output: {llm_output}")
174
+ job_ids = self._parse_action(llm_output, legal_job_ids)
175
+ print(f"Job IDs: {job_ids}")
176
+
177
+ # Ensure we don't schedule multiple jobs on the same machine
178
+ # Track which machines we've already scheduled to avoid conflicts
179
+ scheduled_machines = set()
180
+ filtered_job_ids = []
181
+ for job_id in job_ids:
182
+ # Find the operation for this job
183
+ op = next((op for op in observation.ready_operations if op.job_id == job_id), None)
184
+ if op is not None and op.machine_id not in scheduled_machines:
185
+ filtered_job_ids.append(job_id)
186
+ scheduled_machines.add(op.machine_id)
187
+
188
+ return JSSPAction(job_ids=filtered_job_ids)
189
+
190
+ except Exception as e:
191
+ print(f"Error calling LLM: {e}")
192
+ print(f"Prompt: {prompt}")
193
+ # On error, fall back to empty action (wait)
194
+ return JSSPAction(job_ids=[])
195
+
196
+ @staticmethod
197
+ def _format_machines_status(machines: list[MachineObservation], current_step: int) -> str:
198
+ """Format machine status for prompt."""
199
+ lines = []
200
+ for machine in machines:
201
+ if machine.busy_until is None or machine.busy_until <= current_step:
202
+ status = "Available"
203
+ else:
204
+ status = f"Busy until t={machine.busy_until}"
205
+ job_info = f" (job {machine.current_job_id})" if machine.current_job_id is not None else ""
206
+ lines.append(f" Machine {machine.machine_id}: {status}{job_info}")
207
+ return "\n".join(lines) if lines else " (No machines)"
208
+
209
+ @staticmethod
210
+ def _format_ready_operations(ready_operations: list[ReadyOperationObservation]) -> str:
211
+ """Format ready operations for prompt."""
212
+ lines = []
213
+ for op in ready_operations:
214
+ lines.append(
215
+ f" Job {op.job_id}: Machine {op.machine_id}, Duration {op.duration} min, {op.remaining_ops} ops remaining"
216
+ )
217
+ return "\n".join(lines) if lines else " (No ready operations)"
218
+
219
+ @staticmethod
220
+ def _parse_action(text: str, legal_job_ids: list[int]) -> list[int]:
221
+ """Parse comma-separated job IDs from model output."""
222
+ # First, we remove the reasoning section
223
+ text = text.split("<think>")[-1].split("</think>")[-1].strip()
224
+
225
+ # First, try to split by comma and extract numbers from each part
226
+ # This handles "2,3" or "2, 3" correctly
227
+ parts = text.split(",")
228
+ job_ids = []
229
+
230
+ # Extract numbers from each part (handles "2" or "job 2" or " 2 ")
231
+ for part in parts:
232
+ numbers = re.findall(r"\d+", part.strip())
233
+ for num_str in numbers:
234
+ try:
235
+ job_id = int(num_str)
236
+ if job_id in legal_job_ids:
237
+ job_ids.append(job_id)
238
+ except ValueError:
239
+ continue
240
+
241
+ # If no comma-separated values found, try extracting all numbers
242
+ # (handles cases like "Schedule jobs 2 and 3")
243
+ if not job_ids:
244
+ numbers = re.findall(r"\d+", text)
245
+ for num_str in numbers:
246
+ try:
247
+ job_id = int(num_str)
248
+ if job_id in legal_job_ids:
249
+ job_ids.append(job_id)
250
+ except ValueError:
251
+ continue
252
+
253
+ # Remove duplicates while preserving order
254
+ seen = set()
255
+ unique_job_ids = []
256
+ for job_id in job_ids:
257
+ if job_id not in seen:
258
+ seen.add(job_id)
259
+ unique_job_ids.append(job_id)
260
+
261
+ return unique_job_ids if unique_job_ids else [] # Return empty list if no valid jobs found
src/jssp_openenv/server/__init__.py ADDED
File without changes
src/jssp_openenv/server/app.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from openenv_core.env_server import create_app
2
+
3
+ from ..examples import FT06
4
+ from ..models import JSSPAction, JSSPObservation
5
+ from .jssp_environment import JSSPEnvironment
6
+
7
+ # Create FastAPI app
8
+ env = JSSPEnvironment(FT06)
9
+ app = create_app(env, JSSPAction, JSSPObservation)
src/jssp_openenv/server/jssp_environment.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from copy import deepcopy
3
+ from typing import Optional
4
+
5
+ import simpy
6
+ from openenv_core.env_server import Environment
7
+
8
+ from ..models import JobT, JSSPAction, JSSPObservation, MachineObservation, ReadyOperationObservation
9
+
10
+ # Example of JSSP initial jobs
11
+ # Each tuple is a (machine_index, processing_time)
12
+ #
13
+ # FT06: list[JobT] = [
14
+ # [(2, 1), (0, 3), (1, 6), (3, 7), (5, 3), (4, 6)],
15
+ # [(1, 8), (2, 5), (4, 10), (5, 10), (0, 10), (3, 4)],
16
+ # [(2, 5), (3, 4), (5, 8), (0, 9), (1, 1), (4, 7)],
17
+ # [(1, 5), (0, 5), (2, 5), (3, 3), (4, 8), (5, 9)],
18
+ # [(2, 9), (1, 3), (4, 5), (5, 4), (0, 3), (3, 1)],
19
+ # [(1, 3), (3, 3), (5, 9), (0, 10), (4, 4), (2, 1)],
20
+ # ]
21
+
22
+ PENALTY = 100
23
+
24
+
25
+ class JSSPEnvironment(Environment):
26
+ def __init__(self, jobs: list[JobT]):
27
+ super().__init__()
28
+ self.init_jobs = jobs
29
+ self.reset()
30
+
31
+ def reset(self) -> JSSPObservation:
32
+ """Reset the environment to initial state."""
33
+ self.episode_id = str(uuid.uuid4())
34
+ self.step_count = 0
35
+ self.jobs = deepcopy(self.init_jobs)
36
+ self.nb_machines = max(max(machine for machine, _ in job) for job in self.jobs) + 1
37
+
38
+ # SimPy environment for time tracking
39
+ self.env = simpy.Environment()
40
+
41
+ # Track which operation index each job is currently on
42
+ self.job_progress = [0] * len(self.jobs)
43
+
44
+ # Track machine states
45
+ self.machine_busy_until: list[Optional[int]] = [None] * self.nb_machines
46
+ self.machine_current_job: list[Optional[int]] = [None] * self.nb_machines
47
+
48
+ # Track completed jobs
49
+ self.completed_jobs = 0
50
+
51
+ return self.state
52
+
53
+ def _get_ready_operations(self) -> list[ReadyOperationObservation]:
54
+ """Get all operations that are ready to be scheduled now."""
55
+ ready = []
56
+ for job_id in range(len(self.jobs)):
57
+ # Skip if job is complete
58
+ if self.job_progress[job_id] >= len(self.jobs[job_id]):
59
+ continue
60
+
61
+ # Get next operation for this job
62
+ machine_id, duration = self.jobs[job_id][self.job_progress[job_id]]
63
+
64
+ # Check if machine is available
65
+ busy_until = self.machine_busy_until[machine_id]
66
+ if busy_until is None or busy_until <= self.env.now:
67
+ remaining = len(self.jobs[job_id]) - self.job_progress[job_id]
68
+ ready.append(
69
+ ReadyOperationObservation(
70
+ job_id=job_id,
71
+ machine_id=machine_id,
72
+ duration=duration,
73
+ remaining_ops=remaining,
74
+ )
75
+ )
76
+
77
+ return ready
78
+
79
+ def _at_decision_step(self) -> bool:
80
+ """Check if we're at a decision step (at least one job can be scheduled)."""
81
+ return len(self._get_ready_operations()) > 0
82
+
83
+ def _validate_action(self, action: JSSPAction) -> bool:
84
+ """Validate that an action is legal."""
85
+ scheduled_machines = set()
86
+
87
+ for job_id in action.job_ids:
88
+ # Check job ID is valid
89
+ if job_id < 0 or job_id >= len(self.jobs):
90
+ return False
91
+
92
+ # Check job is not already complete
93
+ if self.job_progress[job_id] >= len(self.jobs[job_id]):
94
+ return False
95
+
96
+ # Get the machine needed for this job's next operation
97
+ machine_id, _ = self.jobs[job_id][self.job_progress[job_id]]
98
+
99
+ # Check machine is available now
100
+ busy_until = self.machine_busy_until[machine_id]
101
+ if busy_until is not None and busy_until > self.env.now:
102
+ return False
103
+
104
+ # Check we're not scheduling two jobs on the same machine
105
+ if machine_id in scheduled_machines:
106
+ return False
107
+
108
+ scheduled_machines.add(machine_id)
109
+
110
+ return True
111
+
112
+ def _schedule_jobs(self, job_ids: list[int]):
113
+ """Schedule the given jobs on their respective machines."""
114
+ for job_id in job_ids:
115
+ machine_id, duration = self.jobs[job_id][self.job_progress[job_id]]
116
+
117
+ # Update machine state
118
+ self.machine_busy_until[machine_id] = int(self.env.now) + duration
119
+ self.machine_current_job[machine_id] = job_id
120
+
121
+ def _advance_to_decision_step(self):
122
+ """Advance simulation time until the next decision step."""
123
+ while True:
124
+ # Stop if we're at a decision step
125
+ if self._at_decision_step():
126
+ break
127
+
128
+ # Stop if all jobs are complete
129
+ if self.completed_jobs >= len(self.jobs):
130
+ break
131
+
132
+ # Find the next time when a machine becomes free
133
+ future_times = [t for t in self.machine_busy_until if t is not None and t > self.env.now]
134
+
135
+ if not future_times:
136
+ # No machines will become free, but not all jobs complete
137
+ # This shouldn't happen in a valid problem
138
+ break
139
+
140
+ next_time = min(future_times)
141
+
142
+ # Advance time to when the next machine becomes free
143
+ self.env.run(until=next_time)
144
+
145
+ # Process completed operations and clear machine state
146
+ for i in range(self.nb_machines):
147
+ if self.machine_busy_until[i] is not None and self.machine_busy_until[i] <= self.env.now:
148
+ # Machine finished processing - advance the job's progress
149
+ job_id = self.machine_current_job[i]
150
+ if job_id is not None:
151
+ self.job_progress[job_id] += 1
152
+
153
+ # Check if job is now complete
154
+ if self.job_progress[job_id] >= len(self.jobs[job_id]):
155
+ self.completed_jobs += 1
156
+
157
+ # Clear machine state
158
+ self.machine_busy_until[i] = None
159
+ self.machine_current_job[i] = None
160
+
161
+ def step(self, action: JSSPAction) -> JSSPObservation:
162
+ """Process an action and advance simulation until next decision step.
163
+
164
+ Returns observation with reward = -(elapsed time) for valid actions,
165
+ or reward = -PENALTY for invalid actions (without updating state).
166
+ """
167
+ start_time = self.env.now
168
+
169
+ # Validate action
170
+ if not self._validate_action(action):
171
+ # Invalid action - return current state with penalty
172
+ obs = self.state
173
+ obs.reward = -PENALTY
174
+ return obs
175
+
176
+ # Schedule the jobs
177
+ self._schedule_jobs(action.job_ids)
178
+
179
+ # Advance simulation to next decision step
180
+ self._advance_to_decision_step()
181
+
182
+ # Calculate reward as negative time elapsed
183
+ time_elapsed = self.env.now - start_time
184
+ reward = -time_elapsed
185
+
186
+ # Increment step counter
187
+ self.step_count = int(self.env.now)
188
+
189
+ # Return observation with reward
190
+ obs = self.state
191
+ obs.reward = reward
192
+
193
+ return obs
194
+
195
+ @property
196
+ def state(self) -> JSSPObservation:
197
+ """Get the current state of the environment, without the reward."""
198
+ machines = [
199
+ MachineObservation(
200
+ machine_id=i,
201
+ busy_until=self.machine_busy_until[i],
202
+ current_job_id=self.machine_current_job[i],
203
+ )
204
+ for i in range(self.nb_machines)
205
+ ]
206
+
207
+ ready_ops = self._get_ready_operations()
208
+
209
+ return JSSPObservation(
210
+ done=self.completed_jobs >= len(self.jobs),
211
+ episode_id=self.episode_id,
212
+ step_count=self.step_count,
213
+ machines=machines,
214
+ ready_operations=ready_ops,
215
+ completed_jobs=self.completed_jobs,
216
+ total_jobs=len(self.jobs),
217
+ reward=0.0, # Default, overwritten in step()
218
+ )
src/jssp_openenv/solver.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .client import JSSPEnvClient
2
+ from .models import ScheduledEvent
3
+ from .policy import JSSPEnvPolicy
4
+
5
+
6
+ def solve_jssp(
7
+ env_client: JSSPEnvClient, policy: JSSPEnvPolicy, max_steps: int, verbose: bool = False
8
+ ) -> tuple[int, list[ScheduledEvent]]:
9
+ """Solve a single JSSP instance using the given policy."""
10
+ result = env_client.reset()
11
+ obs = result.observation
12
+ scheduled_events: list[ScheduledEvent] = []
13
+
14
+ while not result.done:
15
+ if verbose:
16
+ print(f"Step {obs.step_count}: {obs.ready_operations}")
17
+ action = policy.act(obs)
18
+ if verbose:
19
+ print(f"Action: {action}")
20
+
21
+ # Record scheduled events
22
+ if action.job_ids:
23
+ for job_id in action.job_ids:
24
+ operation = next((op for op in obs.ready_operations if op.job_id == job_id), None)
25
+ assert operation is not None
26
+ event = ScheduledEvent(
27
+ job_id=job_id,
28
+ machine_id=operation.machine_id,
29
+ start_time=obs.step_count,
30
+ end_time=obs.step_count + operation.duration,
31
+ )
32
+ scheduled_events.append(event)
33
+
34
+ # Execute action
35
+ result = env_client.step(action)
36
+ obs = result.observation
37
+
38
+ # Safety check to avoid infinite loops
39
+ if obs.step_count >= max_steps:
40
+ print(f"\nWARNING: Exceeded max steps ({max_steps}), terminating")
41
+ break
42
+
43
+ # Extract makespan
44
+ return obs.step_count, scheduled_events
tests/test_models.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from jssp_openenv.models import parse_job_ids
4
+
5
+
6
+ def test_parse_job_ids():
7
+ assert parse_job_ids("1,2,3") == [1, 2, 3]
8
+ assert parse_job_ids("3,2,1") == [3, 2, 1]
9
+ assert parse_job_ids("") == []
10
+ assert parse_job_ids(",") == []
11
+ assert parse_job_ids("0,") == [0]
12
+
13
+ with pytest.raises(ValueError):
14
+ parse_job_ids("1,2,3,a")
15
+
16
+ with pytest.raises(ValueError):
17
+ parse_job_ids("0.1")