diff --git a/.flake8 b/.flake8 index 50dd6cc9..fe349240 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,5 @@ [flake8] -extend-ignore = E203 +extend-ignore = E203, RST303 # Tell flake8-rst-docstrings about Sphinx directives/roles rst-directives = autosummary,seealso,deprecated,doctest rst-roles = class,meth,func,attr diff --git a/README.md b/README.md index a15978f3..b14da492 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ It follows a modular design, allowing users to easily extend the library with ne We support multiple solvers, including: - **Constraint Programming**: Based on OR-Tools' CP-SAT solver. It supports **release dates, deadlines, and due dates.** See the ["Solving the Problem" tutorial](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/tutorial/02-Solving-the-Problem.ipynb) for an example. - **Dispatching Rules**: A set of predefined rules and the ability to create custom ones. They support arbitrary **setup times, machine breakdowns, release dates, deadlines, and due dates**. See the [following example](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/examples/03-Dispatching-Rules.ipynb). You can also create videos or GIFs of the scheduling process. For creating GIFs or videos, see the [Save Gif example](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/examples/04-Save-Gif.ipynb). -- **Metaheuristics**: Currently, we have a **simulated annealing** implementation that supports **release dates, deadlines, and due dates**. We also support arbitrary neighborhood search strategies, including swapping operations in the critical path as described in the paper "Job Shop Scheduling by Simulated Annealing" by van Laarhoven et al. (1992); and energy functions. See our [simulated annealing tutorial](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/tutorial/03-Simulated-Annealing.ipynb). +- **Metaheuristics**: Currently, we have a **simulated annealing** implementation that supports **release dates, deadlines, and due dates**. We also support arbitrary neighborhood search strategies, including swapping operations in the critical path as described in the paper "Job Shop Scheduling by Simulated Annealing" by van Laarhoven et al. (1992); and energy functions. See our [simulated annealing tutorial](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/tutorial/04-Simulated-Annealing.ipynb). - **Reinforcement Learning**: Two Gymnasium environments for solving the problem with **graph neural networks** (GNNs) or any other method. The environments support **setup times, release dates, deadlines, and due dates.** We're currently building a tutorial on how to use them. We also provide useful utilities, data structures, and visualization functions: - **Intuitive Data Structures**: Easily create, manage, and manipulate job shop instances and solutions with user-friendly data structures. See [Getting Started](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/tutorial/00-Getting-Started.ipynb) and [How Solutions are Represented](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/tutorial/01-How-Solutions-are-Represented.ipynb). - **Benchmark Instances**: Load well-known benchmark instances directly from the library without manual downloading. See [Load Benchmark Instances](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/examples/05-Load-Benchmark-Instances.ipynb). -- **Random Instance Generation**: Create random instances with customizable sizes and properties. See [`generation`](https://job-shop-lib.readthedocs.io/en/stable/api/job_shop_lib.generation.html#module-job_shop_lib.generation) module. +- **Random Instance Generation**: Create random instances with customizable sizes and properties. See [`this tutorial`](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/tutorial/03-Generating-New-Instances.ipynb). - **Gantt Charts**: Visualize final schedules and how they are created iteratively by dispatching rule solvers or sequences of scheduling decisions with GIFs or videos. - **Graph Representations**: Represent and visualize instances as disjunctive graphs or agent-task graphs (introduced in the ScheduleNet paper). Build your own custom graphs with the `JobShopGraph` class. See the [Disjunctive Graphs](https://github.com/Pabloo22/job_shop_lib/blob/main/docs/source/tutorial/04-Disjunctive-Graphs.ipynb) and [Resource Task Graphs](https://job-shop-lib.readthedocs.io/en/stable/examples/07-Resource-Task-Graph.html) examples. diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index ed924296..09b9d004 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -7,4 +7,5 @@ Tutorial tutorial/00-Getting-Started tutorial/01-How-Solutions-are-Represented tutorial/02-Solving-the-Problem - tutorial/03-Simulated-Annealing + tutorial/03-Generating-New-Problems + tutorial/04-Simulated-Annealing diff --git a/docs/source/tutorial/03-Generating-New-Problems.ipynb b/docs/source/tutorial/03-Generating-New-Problems.ipynb new file mode 100644 index 00000000..4bd6ed09 --- /dev/null +++ b/docs/source/tutorial/03-Generating-New-Problems.ipynb @@ -0,0 +1,456 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b571493f", + "metadata": {}, + "source": [ + "# Tutorial 03 - Generating New Problems (Random Instance Generation)\n", + "\n", + "This notebook shows how to generate random **JobShopInstance** objects using the function `modular_instance_generator`.\n", + "\n", + "> **Deprecated:** The classes `InstanceGenerator` and `GeneralInstanceGenerator` are deprecated. Use `modular_instance_generator` instead." + ] + }, + { + "cell_type": "markdown", + "id": "4eb87edb", + "metadata": {}, + "source": [ + "## Why a Modular Function?\n", + "The design emphasizes two key patterns:\n", + "\n", + "1. **Dependency Injection** – You inject *callables* that build each matrix (machine routing, durations, release dates, etc.) instead of the generator hard-coding logic. This increases testability and flexibility.\n", + "2. **Strategy Pattern** – Each callable acts as a strategy. Swap routing, duration, or release-date strategies independently without modifying the core generator.\n", + "\n", + "The generator returns an **infinite stream** of instances. Use `next()` or slice it with `itertools.islice`." + ] + }, + { + "cell_type": "markdown", + "id": "8e96b3df", + "metadata": {}, + "source": [ + "## Signature (Annotated)\n", + "```python\n", + "modular_instance_generator(\n", + " machine_matrix_creator: Callable[[random.Random], MachineMatrix],\n", + " duration_matrix_creator: Callable[[MachineMatrix, random.Random], DurationMatrix],\n", + " *,\n", + " name_creator: Callable[[int], str] = lambda i: f\"generated_instance_{i}\",\n", + " release_dates_matrix_creator: Callable[[DurationMatrix, random.Random], ReleaseDatesMatrix] | None = None,\n", + " deadlines_matrix_creator: Callable[[DurationMatrix, random.Random], DeadlinesMatrix] | None = None,\n", + " due_dates_matrix_creator: Callable[[DurationMatrix, random.Random], DueDatesMatrix] | None = None,\n", + " seed: int | None = None,\n", + ") -> Generator[JobShopInstance, None, None]\n", + "```\n", + "Where each *Matrix* is a nested list (ragged lists allowed for jobs)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3da71e8d", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports and basic setup\n", + "from itertools import islice\n", + "from job_shop_lib.generation import (\n", + " modular_instance_generator,\n", + " get_default_machine_matrix_creator,\n", + " get_default_duration_matrix_creator,\n", + " range_size_selector,\n", + " choice_size_selector,\n", + " create_release_dates_matrix,\n", + " get_mixed_release_date_strategy,\n", + " compute_horizon_proxy,\n", + ")\n", + "import random\n", + "\n", + "# For deterministic examples\n", + "BASE_SEED = 42" + ] + }, + { + "cell_type": "markdown", + "id": "da62385c", + "metadata": {}, + "source": [ + "## Basic Example\n", + "Generate a fixed 3x3 instance without recirculation and durations in `[1, 10]`." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "70bc9b22", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(JobShopInstance(name=generated_instance_0, num_jobs=3, num_machines=3),\n", + " array([[ 5., 6., 4.],\n", + " [ 5., 7., 10.],\n", + " [ 9., 9., 5.]], dtype=float32))" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "machine_creator = get_default_machine_matrix_creator(\n", + " size_selector=lambda rng: (3, 3),\n", + " with_recirculation=False,\n", + ")\n", + "duration_creator = get_default_duration_matrix_creator((1, 10))\n", + "\n", + "gen_basic = modular_instance_generator(\n", + " machine_matrix_creator=machine_creator,\n", + " duration_matrix_creator=duration_creator,\n", + " seed=BASE_SEED,\n", + ")\n", + "inst = next(gen_basic)\n", + "inst, inst.duration_matrix_array" + ] + }, + { + "cell_type": "markdown", + "id": "75085acf", + "metadata": {}, + "source": [ + "## Custom Naming Strategy\n", + "Provide a callable that maps the index to a formatted name." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a5750583", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['expA_seed42_000', 'expA_seed42_001', 'expA_seed42_002']" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def fancy_name(i: int) -> str: # zero-padded index\n", + " return f\"expA_seed{BASE_SEED}_{i:03d}\"\n", + "\n", + "\n", + "gen_named = modular_instance_generator(\n", + " machine_matrix_creator=machine_creator,\n", + " duration_matrix_creator=duration_creator,\n", + " name_creator=fancy_name,\n", + " seed=BASE_SEED,\n", + ")\n", + "[next(gen_named).name for _ in range(3)]" + ] + }, + { + "cell_type": "markdown", + "id": "60c96d95", + "metadata": {}, + "source": [ + "## Random Sizes (Size Selectors)\n", + "Use range-based or discrete-choice size selectors captured in the machine matrix creator." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "1824aa33", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[JobShopInstance(name=generated_instance_0, num_jobs=4, num_machines=5),\n", + " JobShopInstance(name=generated_instance_1, num_jobs=6, num_machines=4)]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Range-based size selection\n", + "machine_creator_range = get_default_machine_matrix_creator(\n", + " size_selector=lambda rng: range_size_selector(\n", + " rng,\n", + " num_jobs_range=(5, 7),\n", + " num_machines_range=(3, 5),\n", + " allow_less_jobs_than_machines=True,\n", + " ),\n", + " with_recirculation=True,\n", + ")\n", + "\n", + "# Choice-based size selection\n", + "OPTIONS = [(3, 3), (4, 5), (6, 4)]\n", + "machine_creator_choice = get_default_machine_matrix_creator(\n", + " size_selector=lambda rng: choice_size_selector(rng, OPTIONS),\n", + " with_recirculation=False,\n", + ")\n", + "\n", + "duration_creator_var = get_default_duration_matrix_creator((2, 15))\n", + "\n", + "gen_choice = modular_instance_generator(\n", + " machine_matrix_creator=machine_creator_choice,\n", + " duration_matrix_creator=duration_creator_var,\n", + " seed=7,\n", + ")\n", + "[next(gen_choice) for _ in range(2)]" + ] + }, + { + "cell_type": "markdown", + "id": "fc4f0db8", + "metadata": {}, + "source": [ + "## Adding Release Dates\n", + "You can wrap `create_release_dates_matrix` with a custom mixed strategy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6d78183", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[[0, 6, 9], [0, 4, 7], [4, 5, 8]]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def release_dates_creator(duration_matrix, rng):\n", + " horizon = compute_horizon_proxy(duration_matrix)\n", + " strat = get_mixed_release_date_strategy(0.6, 0.4, horizon)\n", + " return create_release_dates_matrix(\n", + " duration_matrix, strategy=strat, rng=rng\n", + " )\n", + "\n", + "\n", + "gen_with_release = modular_instance_generator(\n", + " machine_matrix_creator=machine_creator,\n", + " duration_matrix_creator=duration_creator,\n", + " release_dates_matrix_creator=release_dates_creator,\n", + " seed=123,\n", + ")\n", + "inst_with_release = next(gen_with_release)\n", + "inst_with_release.release_dates_matrix" + ] + }, + { + "cell_type": "markdown", + "id": "163cf717", + "metadata": {}, + "source": [ + "## Deadlines & Due Dates\n", + "Provide simple heuristic strategies. You decide semantics." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dddd5aa9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "JobShopInstance(name=generated_instance_0, num_jobs=3, num_machines=3)\n", + "complex_instance.due_dates_matrix_array =\n", + "[[14. 9. 25.]\n", + " [ 4. 12. 14.]\n", + " [13. 18. 27.]]\n", + "complex_instance.deadlines_matrix_array =\n", + "[[14. 18. 38.]\n", + " [ 4. 18. 24.]\n", + " [14. 30. 50.]]\n", + "complex_instance.release_dates_matrix_array =\n", + "[[ 3. 6. 8.]\n", + " [ 3. 3. 6.]\n", + " [ 4. 10. 10.]]\n" + ] + } + ], + "source": [ + "def deadlines_creator(duration_matrix, rng):\n", + " deadlines = []\n", + " for job in duration_matrix:\n", + " cum = 0\n", + " row = []\n", + " for duration in job:\n", + " cum += duration\n", + " row.append(int(cum * 2)) # 100% slack\n", + " deadlines.append(row)\n", + " return deadlines\n", + "\n", + "\n", + "def due_dates_creator(duration_matrix, rng):\n", + " due_dates = []\n", + " for job in duration_matrix:\n", + " cum = 0\n", + " row = []\n", + " for duration in job:\n", + " cum += duration\n", + " row.append(cum + rng.randint(0, duration))\n", + " due_dates.append(row)\n", + " return due_dates\n", + "\n", + "\n", + "gen_with_all = modular_instance_generator(\n", + " machine_matrix_creator=machine_creator,\n", + " duration_matrix_creator=duration_creator,\n", + " release_dates_matrix_creator=release_dates_creator,\n", + " deadlines_matrix_creator=deadlines_creator,\n", + " due_dates_matrix_creator=due_dates_creator,\n", + " seed=99,\n", + ")\n", + "complex_instance = next(gen_with_all)\n", + "print(complex_instance)\n", + "print(\"complex_instance.due_dates_matrix_array =\")\n", + "print(complex_instance.due_dates_matrix_array)\n", + "print(\"complex_instance.deadlines_matrix_array =\")\n", + "print(complex_instance.deadlines_matrix_array)\n", + "print(\"complex_instance.release_dates_matrix_array =\")\n", + "print(complex_instance.release_dates_matrix_array)" + ] + }, + { + "cell_type": "markdown", + "id": "80bd8882", + "metadata": {}, + "source": [ + "## Migration Example (Deprecated Class)\n", + "Old approach (for reference, no execution here):\n", + "```python\n", + "# Deprecated\n", + "# gen = GeneralInstanceGenerator(num_jobs=(5,10), num_machines=(4,6), duration_range=(2,20))\n", + "# inst = gen.generate()\n", + "```\n", + "New modular approach:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "57a6eb24", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "JobShopInstance(name=generated_instance_0, num_jobs=8, num_machines=5)" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from functools import partial\n", + "\n", + "size_selector = partial(\n", + " range_size_selector,\n", + " num_jobs_range=(5, 8),\n", + " num_machines_range=(4, 6),\n", + " allow_less_jobs_than_machines=False,\n", + ")\n", + "migrated_machine_creator = get_default_machine_matrix_creator(\n", + " size_selector=size_selector, with_recirculation=False\n", + ")\n", + "migrated_duration_creator = get_default_duration_matrix_creator((2, 20))\n", + "gen_migrated = modular_instance_generator(\n", + " migrated_machine_creator, migrated_duration_creator, seed=0\n", + ")\n", + "next(gen_migrated)" + ] + }, + { + "cell_type": "markdown", + "id": "58650c8a", + "metadata": {}, + "source": [ + "## Finite Sample Extraction\n", + "Use `itertools.islice` to limit the infinite generator." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "01d7d25f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(3, ['generated_instance_1', 'generated_instance_2', 'generated_instance_3'])" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "finite_instances = list(islice(gen_basic, 3)) # reuse earlier generator\n", + "len(finite_instances), [inst.name for inst in finite_instances]" + ] + }, + { + "cell_type": "markdown", + "id": "b26f7fee", + "metadata": {}, + "source": [ + "## Summary\n", + "`modular_instance_generator` provides a clean, composable way to build\n", + "random Job Shop instances. You control routing, durations, and time-related\n", + "constraints via strategy callables—simplifying experimentation and testing.\n", + "\n", + "You can now integrate these instances into solvers, RL environments, or graph builders." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "job-shop-lib-gOF0HMZJ-py3.12", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorial/03-Simulated-Annealing.ipynb b/docs/source/tutorial/04-Simulated-Annealing.ipynb similarity index 54% rename from docs/source/tutorial/03-Simulated-Annealing.ipynb rename to docs/source/tutorial/04-Simulated-Annealing.ipynb index 07ef2238..62711977 100644 --- a/docs/source/tutorial/03-Simulated-Annealing.ipynb +++ b/docs/source/tutorial/04-Simulated-Annealing.ipynb @@ -123,7 +123,7 @@ "output_type": "stream", "text": [ " Temperature Energy Accept Improve Elapsed Remaining\n", - " 2.50000 67.00 100.00% 20.00% 0:00:03 0:00:00" + " 2.50000 67.00 100.00% 20.00% 0:00:01 0:00:00" ] }, { @@ -170,7 +170,7 @@ "output_type": "stream", "text": [ " Temperature Energy Accept Improve Elapsed Remaining\n", - " 2.50000 70.00 50.00% 0.00% 0:00:09 0:00:00" + " 2.50000 70.00 50.00% 0.00% 0:00:04 0:00:00" ] }, { @@ -213,7 +213,19 @@ "source": [ "### Handling Deadlines\n", "\n", - "The solver can handle constraints like deadlines. The `energy` function will add a large penalty for any schedule where an operation finishes after its deadline. This guides the search towards solutions that respect the deadlines. You can specify how to penalize these violations with the `due_date_penalty_factor` and the `deadline_penalty_factor` parameters when creating the `SimulatedAnnealingSolver`." + "In this section we illustrate how simulated annealing can improve a schedule when **deadlines** are present.\n", + "\n", + "We will:\n", + "\n", + "1. Generate a random 6x6 instance (6 jobs, 6 machines) with operation deadlines using the generation module.\n", + "2. Build a baseline schedule with a dispatching rule (Most Work Remaining, for example).\n", + "3. Count deadline violations in the baseline schedule.\n", + "4. Use the `SimulatedAnnealingSolver` with a penalty-aware objective to reduce (ideally eliminate) violations. This may increase makespan but satisfies constraints.\n", + "5. Compare before/after metrics (violations, makespan, objective value).\n", + "\n", + "Deadlines here are synthetic: for each job we accumulate processing times and assign each operation a deadline = cumulative_duration * 2 (rounded with ceil). You can adapt the strategy for your domain.\n", + "\n", + "Below we first construct the instance and a baseline schedule, then run Simulated Annealing that explicitly penalizes deadline violations. A large penalty coefficient strongly biases the search toward feasibility (meeting deadlines) even at the cost of a longer makespan.\n" ] }, { @@ -227,38 +239,211 @@ "output_type": "stream", "text": [ " Temperature Energy Accept Improve Elapsed Remaining\n", - " 2.50000 60.00 70.00% 20.00% 0:00:00 0:00:00" + " 5266.12307 10084.00 43.80% 21.50% 0:00:00 0:00:01" ] }, { "name": "stdout", "output_type": "stream", "text": [ - "The makespan found for ft06 is: 55\n" + "JobShopInstance(name=generated_instance_0, num_jobs=6, num_machines=6)\n", + "Baseline makespan: 86\n", + "Baseline deadline violations: 9\n", + "Optimal (CP-SAT) makespan: 84\n", + "Optimal (CP-SAT) deadline violations: 0\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 5.00000 10084.00 0.00% 0.00% 0:00:01 0:00:00" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "After Simulated Annealing:\n", + "Improved makespan: 84\n", + "Improved deadline violations: 0\n", + "Improved objective (makespan + penalties): 84.0\n", + "\n", + "Comparative Summary:\n", + "Baseline objective: 90086.0\n", + "Optimal (CP-SAT) objective: 84.0\n", + "Delta Baseline -> SA objective: -90002.0\n", + "Delta SA -> Optimal objective: 0.0\n", + "Delta Baseline -> SA makespan: -2\n", + "Delta SA -> Optimal makespan: 0\n", + "Deadline violations reduced ✅\n", + "All deadlines met after annealing (feasible schedule obtained).\n" ] } ], "source": [ - "from job_shop_lib.metaheuristics import get_makespan_with_penalties_objective\n", + "# Deadlines example: baseline vs simulated annealing\n", + "import math\n", + "from job_shop_lib.generation import (\n", + " modular_instance_generator,\n", + " get_default_machine_matrix_creator,\n", + " get_default_duration_matrix_creator,\n", + ")\n", + "from job_shop_lib.dispatching.rules import (\n", + " DispatchingRuleSolver,\n", + " most_work_remaining_rule,\n", + ")\n", + "from job_shop_lib.metaheuristics import (\n", + " SimulatedAnnealingSolver,\n", + " get_makespan_with_penalties_objective,\n", + ")\n", + "from job_shop_lib.constraint_programming import ORToolsSolver\n", + "from job_shop_lib.exceptions import NoSolutionFoundError\n", "\n", + "# 1. Create a 6x6 instance (fixed size) with synthetic deadlines\n", + "SEED = 123\n", + "NUM_JOBS = 6\n", + "NUM_MACHINES = 6\n", "\n", - "solver = SimulatedAnnealingSolver(\n", - " seed=42,\n", - " initial_temperature=10,\n", - " steps=1000,\n", - " updates=100,\n", - " objective_function=get_makespan_with_penalties_objective(\n", - " deadline_penalty_factor=10_000, # We can tune these factors here\n", - " due_date_penalty_factor=100,\n", - " ), # Since there are no extra constraints in ft06, this returns the makespan\n", + "machine_creator = get_default_machine_matrix_creator(\n", + " size_selector=lambda rng: (NUM_JOBS, NUM_MACHINES),\n", + " with_recirculation=False,\n", ")\n", + "# Durations between 2 and 15 to have some variability\n", + "duration_creator = get_default_duration_matrix_creator((2, 15))\n", + "\n", + "\n", + "def deadlines_creator(duration_matrix, rng):\n", + " deadlines: list[list[int]] = []\n", + " for job_row in duration_matrix:\n", + " cum = 0\n", + " row = []\n", + " for d in job_row:\n", + " cum += d\n", + " # 100% slack factor\n", + " row.append(math.ceil(cum * 2))\n", + " deadlines.append(row)\n", + " return deadlines\n", + "\n", + "\n", + "instance_gen = modular_instance_generator(\n", + " machine_matrix_creator=machine_creator,\n", + " duration_matrix_creator=duration_creator,\n", + " deadlines_matrix_creator=deadlines_creator,\n", + " seed=SEED,\n", + ")\n", + "for i, instance in enumerate(instance_gen):\n", + " # Compute a (near) perfect solution via CP-SAT for comparison\n", + " cp_solver = ORToolsSolver()\n", + " try:\n", + " perfect_schedule = cp_solver.solve(instance)\n", + " break\n", + " except NoSolutionFoundError:\n", + " print(f\"Instance {i} has no feasible solution, retrying...\")\n", + "print(instance)\n", + "\n", + "# 2. Baseline schedule with a dispatching rule\n", + "baseline_solver = DispatchingRuleSolver(\n", + " dispatching_rule=most_work_remaining_rule\n", + ")\n", + "baseline_schedule = baseline_solver.solve(instance)\n", "\n", - "# Solve the problem\n", - "schedule = solver.solve(ft06_instance)\n", + "# Helper: count deadline violations\n", "\n", - "makespan = schedule.makespan()\n", - "print(f\"The makespan found for ft06 is: {makespan}\")" + "\n", + "def count_deadline_violations(schedule):\n", + " violations = 0\n", + " for machine_sched in schedule.schedule:\n", + " for s_op in machine_sched:\n", + " op = s_op.operation\n", + " if op.deadline is not None and s_op.end_time > op.deadline:\n", + " violations += 1\n", + " return violations\n", + "\n", + "\n", + "baseline_makespan = baseline_schedule.makespan()\n", + "baseline_violations = count_deadline_violations(baseline_schedule)\n", + "print(f\"Baseline makespan: {baseline_makespan}\")\n", + "print(f\"Baseline deadline violations: {baseline_violations}\")\n", + "\n", + "# Perfect (CP) reference\n", + "perfect_makespan = perfect_schedule.makespan()\n", + "perfect_violations = count_deadline_violations(perfect_schedule)\n", + "print(f\"Optimal (CP-SAT) makespan: {perfect_makespan}\")\n", + "print(f\"Optimal (CP-SAT) deadline violations: {perfect_violations}\")\n", + "\n", + "# 3. Simulated Annealing with penalty-aware objective\n", + "objective = get_makespan_with_penalties_objective(\n", + " deadline_penalty_factor=10_000 # large => prioritize feasibility\n", + ")\n", + "sa_solver = SimulatedAnnealingSolver(\n", + " seed=SEED,\n", + " initial_temperature=30_000, # moderate starting temperature\n", + " ending_temperature=5, # cool down\n", + " steps=10_000, # reasonable effort for tutorial\n", + " updates=10,\n", + " objective_function=objective,\n", + ")\n", + "\n", + "improved_schedule = sa_solver.solve(instance, initial_state=baseline_schedule)\n", + "improved_makespan = improved_schedule.makespan()\n", + "improved_violations = count_deadline_violations(improved_schedule)\n", + "\n", + "# Objective values (makespan + penalties)\n", + "baseline_objective = objective(baseline_schedule)\n", + "improved_objective = objective(improved_schedule)\n", + "perfect_objective = objective(perfect_schedule)\n", + "\n", + "print(\"\\nAfter Simulated Annealing:\")\n", + "print(f\"Improved makespan: {improved_makespan}\")\n", + "print(f\"Improved deadline violations: {improved_violations}\")\n", + "print(f\"Improved objective (makespan + penalties): {improved_objective}\")\n", + "\n", + "# 4. Comparative summary vs baseline and optimal\n", + "print(\"\\nComparative Summary:\")\n", + "print(f\"Baseline objective: {baseline_objective}\")\n", + "print(f\"Optimal (CP-SAT) objective: {perfect_objective}\")\n", + "print(\n", + " f\"Delta Baseline -> SA objective: {improved_objective - baseline_objective}\"\n", + ")\n", + "print(\n", + " f\"Delta SA -> Optimal objective: {improved_objective - perfect_objective}\"\n", + ")\n", + "print(\n", + " f\"Delta Baseline -> SA makespan: {improved_makespan - baseline_makespan}\"\n", + ")\n", + "print(f\"Delta SA -> Optimal makespan: {improved_makespan - perfect_makespan}\")\n", + "\n", + "if improved_violations < baseline_violations:\n", + " print(\"Deadline violations reduced ✅\")\n", + "elif improved_violations == baseline_violations:\n", + " print(\"No change in deadline violations\")\n", + "else:\n", + " print(\"More violations (unexpected if penalty large)\")\n", + "\n", + "if improved_violations == 0 and baseline_violations > 0:\n", + " print(\"All deadlines met after annealing (feasible schedule obtained).\")\n", + "\n", + "if perfect_violations == 0 and improved_violations > 0:\n", + " print(\n", + " \"Note: CP-SAT met all deadlines while SA still has violations — consider higher penalty or more steps.\"\n", + " )" ] + }, + { + "cell_type": "markdown", + "id": "519c7af9", + "metadata": {}, + "source": [ + "Note that, in this case, we needed to use a higher initial temperature to effectively explore the solution space and reduce deadline violations. Otherwise, because of the high penalty, very few solutions would be accepted, hindering the search process. In general, the more violations we expect, the higher the initial temperature should be to allow the algorithm to explore a wider range of solutions." + ] + }, + { + "cell_type": "markdown", + "id": "51f0604d", + "metadata": {}, + "source": [] } ], "metadata": { diff --git a/job_shop_lib/_job_shop_instance.py b/job_shop_lib/_job_shop_instance.py index efafc727..a9f8f204 100644 --- a/job_shop_lib/_job_shop_instance.py +++ b/job_shop_lib/_job_shop_instance.py @@ -40,12 +40,12 @@ class JobShopInstance: num_machines num_operations is_flexible - durations_matrix + duration_matrix machines_matrix release_dates_matrix deadlines_matrix due_dates_matrix - durations_matrix_array + duration_matrix_array machines_matrix_array operations_by_machine max_duration @@ -216,7 +216,7 @@ def to_dict(self) -> dict[str, Any]: { "name": self.name, - "duration_matrix": self.durations_matrix, + "duration_matrix": self.duration_matrix, "machines_matrix": self.machines_matrix, "metadata": self.metadata, # Optionally (if the instance has them): @@ -227,7 +227,7 @@ def to_dict(self) -> dict[str, Any]: """ data = { "name": self.name, - "duration_matrix": self.durations_matrix, + "duration_matrix": self.duration_matrix, "machines_matrix": self.machines_matrix, "metadata": self.metadata, } @@ -374,6 +374,22 @@ def has_due_dates(self) -> bool: @functools.cached_property def durations_matrix(self) -> list[list[int]]: + """Another name for `duration_matrix` attribute, kept for + backward compatibility. + + It may be removed in future versions. + """ + warnings.warn( + "`duration_matrix` attribute is deprecated and will be " + "removed in future versions. Please use `duration_matrix` " + "property instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.duration_matrix + + @functools.cached_property + def duration_matrix(self) -> list[list[int]]: """Returns the duration matrix of the instance. The duration of the operation with ``job_id`` i and ``position_in_job`` @@ -452,7 +468,23 @@ def durations_matrix_array(self) -> NDArray[np.float32]: If the jobs have different number of operations, the matrix is padded with ``np.nan`` to make it rectangular. """ - return self._fill_matrix_with_nans_2d(self.durations_matrix) + warnings.warn( + "`durations_matrix_array` attribute is deprecated and will be " + "removed in future versions. Please use `duration_matrix_array` " + "property instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.duration_matrix_array + + @property + def duration_matrix_array(self) -> NDArray[np.float32]: + """Returns the duration matrix of the instance as a numpy array. + + If the jobs have different number of operations, the matrix is + padded with ``np.nan`` to make it rectangular. + """ + return self._fill_matrix_with_nans_2d(self.duration_matrix) @functools.cached_property def release_dates_matrix_array(self) -> NDArray[np.float32]: diff --git a/job_shop_lib/benchmarking/__init__.py b/job_shop_lib/benchmarking/__init__.py index 6a1b424b..26071548 100644 --- a/job_shop_lib/benchmarking/__init__.py +++ b/job_shop_lib/benchmarking/__init__.py @@ -6,6 +6,7 @@ load_all_benchmark_instances load_benchmark_instance load_benchmark_json + load_benchmark_group You can load a benchmark instance from the library: @@ -93,10 +94,12 @@ load_all_benchmark_instances, load_benchmark_instance, load_benchmark_json, + load_benchmark_group, ) __all__ = [ "load_all_benchmark_instances", "load_benchmark_instance", "load_benchmark_json", + "load_benchmark_group", ] diff --git a/job_shop_lib/benchmarking/_load_benchmark.py b/job_shop_lib/benchmarking/_load_benchmark.py index 48c1d8fa..84442d41 100644 --- a/job_shop_lib/benchmarking/_load_benchmark.py +++ b/job_shop_lib/benchmarking/_load_benchmark.py @@ -86,3 +86,28 @@ def load_benchmark_json() -> dict[str, dict[str, Any]]: with benchmark_file.open("r", encoding="utf-8") as f: return json.load(f) + + +@functools.cache +def load_benchmark_group(group_prefix: str) -> list[JobShopInstance]: + """Loads a group of benchmark instances whose names start with the given + prefix. + + Args: + group_prefix: + The prefix of the benchmark instances to load. For example, + if the prefix is "la", all instances whose names start with "la" + (e.g., "la01-40", "la02-40", etc.) will be loaded. + + Returns: + A list of :class:`JobShopInstance` objects whose names start with + the given prefix. + + .. versionadded:: 1.7.0 + """ + all_instances = load_all_benchmark_instances() + return [ + instance + for name, instance in all_instances.items() + if name.startswith(group_prefix) + ] diff --git a/job_shop_lib/dispatching/feature_observers/_duration_observer.py b/job_shop_lib/dispatching/feature_observers/_duration_observer.py index 1bc631d5..d21daec0 100644 --- a/job_shop_lib/dispatching/feature_observers/_duration_observer.py +++ b/job_shop_lib/dispatching/feature_observers/_duration_observer.py @@ -58,7 +58,7 @@ def update(self, scheduled_operation: ScheduledOperation): mapping[feature_type](scheduled_operation) def _initialize_operation_durations(self): - duration_matrix = self.dispatcher.instance.durations_matrix_array + duration_matrix = self.dispatcher.instance.duration_matrix_array operation_durations = np.array(duration_matrix).reshape(-1, 1) # Drop the NaN values operation_durations = operation_durations[ diff --git a/job_shop_lib/dispatching/feature_observers/_earliest_start_time_observer.py b/job_shop_lib/dispatching/feature_observers/_earliest_start_time_observer.py index e5a819a6..7d295ca8 100644 --- a/job_shop_lib/dispatching/feature_observers/_earliest_start_time_observer.py +++ b/job_shop_lib/dispatching/feature_observers/_earliest_start_time_observer.py @@ -80,7 +80,7 @@ def __init__( # Earliest start times initialization # ------------------------------- - squared_duration_matrix = dispatcher.instance.durations_matrix_array + squared_duration_matrix = dispatcher.instance.duration_matrix_array self.earliest_start_times: NDArray[np.float32] = np.hstack( ( np.zeros((squared_duration_matrix.shape[0], 1), dtype=float), diff --git a/job_shop_lib/generation/__init__.py b/job_shop_lib/generation/__init__.py index ba2621f7..a455f757 100644 --- a/job_shop_lib/generation/__init__.py +++ b/job_shop_lib/generation/__init__.py @@ -5,16 +5,46 @@ InstanceGenerator GeneralInstanceGenerator - generate_duration_matrix + modular_instance_generator generate_machine_matrix_with_recirculation generate_machine_matrix_without_recirculation + generate_duration_matrix + range_size_selector + choice_size_selector + get_default_machine_matrix_creator + get_default_duration_matrix_creator + ReleaseDateStrategy + create_release_dates_matrix + get_independent_release_date_strategy + get_cumulative_release_date_strategy + get_mixed_release_date_strategy + compute_horizon_proxy """ -from job_shop_lib.generation._utils import ( - generate_duration_matrix, +from job_shop_lib.generation._size_selectors import ( + range_size_selector, + choice_size_selector, +) +from job_shop_lib.generation._machine_matrix import ( generate_machine_matrix_with_recirculation, generate_machine_matrix_without_recirculation, + get_default_machine_matrix_creator, +) +from job_shop_lib.generation._duration_matrix import ( + get_default_duration_matrix_creator, + generate_duration_matrix, +) +from job_shop_lib.generation._release_date_matrix import ( + ReleaseDateStrategy, + create_release_dates_matrix, + get_independent_release_date_strategy, + get_cumulative_release_date_strategy, + get_mixed_release_date_strategy, + compute_horizon_proxy, +) +from job_shop_lib.generation._modular_instance_generator import ( + modular_instance_generator, ) from job_shop_lib.generation._instance_generator import InstanceGenerator from job_shop_lib.generation._general_instance_generator import ( @@ -27,4 +57,15 @@ "generate_duration_matrix", "generate_machine_matrix_with_recirculation", "generate_machine_matrix_without_recirculation", + "modular_instance_generator", + "range_size_selector", + "choice_size_selector", + "get_default_machine_matrix_creator", + "get_default_duration_matrix_creator", + "ReleaseDateStrategy", + "create_release_dates_matrix", + "get_independent_release_date_strategy", + "get_cumulative_release_date_strategy", + "get_mixed_release_date_strategy", + "compute_horizon_proxy", ] diff --git a/job_shop_lib/generation/_duration_matrix.py b/job_shop_lib/generation/_duration_matrix.py new file mode 100644 index 00000000..1a46a8af --- /dev/null +++ b/job_shop_lib/generation/_duration_matrix.py @@ -0,0 +1,83 @@ +from collections.abc import Callable +import random + +import numpy as np +from numpy.typing import NDArray + +from job_shop_lib.exceptions import ValidationError + + +def get_default_duration_matrix_creator( + duration_range: tuple[int, int] = (1, 99), +) -> Callable[ + [list[list[list[int]]] | list[list[int]], random.Random], + list[list[int]], +]: + """Creates a duration matrix generator function. + + Internally, it wraps :func:`generate_duration_matrix`. + + .. note:: + + This function assumes that the machine matrix has the shape (num_jobs, + num_machines). + + Args: + duration_range: + A tuple specifying the inclusive range for operation durations. + + Returns: + A callable that generates a duration matrix of shape (num_jobs, + num_machines) when called with a machine matrix and a + `random.Random` instance. + """ + + def duration_matrix_creator( + machine_matrix: list[list[list[int]]] | list[list[int]], + rng: random.Random, + ) -> list[list[int]]: + seed_for_np = rng.randint(0, 2**16 - 1) + numpy_rng = np.random.default_rng(seed_for_np) + num_jobs = len(machine_matrix) + num_machines = len(machine_matrix[0]) + return generate_duration_matrix( + num_jobs, num_machines, duration_range, numpy_rng + ).tolist() + + return duration_matrix_creator + + +def generate_duration_matrix( + num_jobs: int, + num_machines: int, + duration_range: tuple[int, int], + rng: np.random.Generator | None = None, +) -> NDArray[np.int32]: + """Generates a duration matrix. + + Args: + num_jobs: The number of jobs. + num_machines: The number of machines. + duration_range: The range of the duration values. + rng: A numpy random number generator. + + Returns: + A duration matrix with shape (num_jobs, num_machines). + """ + rng = rng or np.random.default_rng() + if duration_range[0] > duration_range[1]: + raise ValidationError( + "The lower bound of the duration range must be less than or equal " + "to the upper bound." + ) + if num_jobs <= 0: + raise ValidationError("The number of jobs must be greater than 0.") + if num_machines <= 0: + raise ValidationError("The number of machines must be greater than 0.") + + return rng.integers( + duration_range[0], + duration_range[1] + 1, + size=(num_jobs, num_machines), + dtype=np.int32, + ) diff --git a/job_shop_lib/generation/_instance_generator.py b/job_shop_lib/generation/_instance_generator.py index 9339de08..195d2861 100644 --- a/job_shop_lib/generation/_instance_generator.py +++ b/job_shop_lib/generation/_instance_generator.py @@ -13,13 +13,13 @@ class InstanceGenerator(ABC): """Common interface for all generators. The class supports both single instance generation and iteration over - multiple instances, controlled by the `iteration_limit` parameter. It - implements the iterator protocol, allowing it to be used in a `for` loop. + multiple instances, controlled by the ``iteration_limit`` parameter. It + implements the iterator protocol, allowing it to be used in a ``for`` loop. Note: When used as an iterator, the generator will produce instances until it - reaches the specified `iteration_limit`. If `iteration_limit` is None, - it will continue indefinitely. + reaches the specified ``iteration_limit``. If ``iteration_limit`` is + ``None``, it will continue indefinitely. Attributes: num_jobs_range: @@ -84,9 +84,11 @@ def generate( """Generates a single job shop instance Args: - num_jobs: The number of jobs to generate. If None, a random value + num_jobs: + The number of jobs to generate. If None, a random value within the specified range will be used. - num_machines: The number of machines to generate. If None, a random + num_machines: + The number of machines to generate. If None, a random value within the specified range will be used. """ diff --git a/job_shop_lib/generation/_utils.py b/job_shop_lib/generation/_machine_matrix.py similarity index 51% rename from job_shop_lib/generation/_utils.py rename to job_shop_lib/generation/_machine_matrix.py index aaf5f426..bc5d9fa9 100644 --- a/job_shop_lib/generation/_utils.py +++ b/job_shop_lib/generation/_machine_matrix.py @@ -1,43 +1,62 @@ +from collections.abc import Callable +import random + import numpy as np from numpy.typing import NDArray from job_shop_lib.exceptions import ValidationError -def generate_duration_matrix( - num_jobs: int, - num_machines: int, - duration_range: tuple[int, int], - rng: np.random.Generator | None = None, -) -> NDArray[np.int32]: - """Generates a duration matrix. +def get_default_machine_matrix_creator( + size_selector: Callable[[random.Random], tuple[int, int]] = ( + lambda _: (10, 10) + ), + with_recirculation: bool = True, +) -> Callable[[random.Random], list[list[list[int]]] | list[list[int]]]: + """Creates a machine matrix generator function. + + Internally, it wraps either + :func:`generate_machine_matrix_with_recirculation` + or :func:`generate_machine_matrix_without_recirculation` + based on the `with_recirculation` parameter. + + .. note:: + + The generated machine matrix will have the shape (num_jobs, + num_machines). Args: - num_jobs: The number of jobs. - num_machines: The number of machines. - duration_range: The range of the duration values. - rng: A numpy random number generator. + rng: + A random.Random instance. + size_selector: + A callable that takes a random.Random instance and returns a + tuple (num_jobs, num_machines). + with_recirculation: + If ``True``, generates a machine matrix with recirculation; + otherwise, without recirculation. Recirculation means that a job + can visit the same machine more than once. Returns: - A duration matrix with shape (num_jobs, num_machines). + A callable that generates a machine matrix when called with a + random.Random instance. """ - rng = rng or np.random.default_rng() - if duration_range[0] > duration_range[1]: - raise ValidationError( - "The lower bound of the duration range must be less than or equal " - "to the upper bound." - ) - if num_jobs <= 0: - raise ValidationError("The number of jobs must be greater than 0.") - if num_machines <= 0: - raise ValidationError("The number of machines must be greater than 0.") - return rng.integers( - duration_range[0], - duration_range[1] + 1, - size=(num_jobs, num_machines), - dtype=np.int32, - ) + def generator( + rng: random.Random, + ) -> list[list[list[int]]]: + num_jobs, num_machines = size_selector(rng) + seed_for_np = rng.randint(0, 2**16 - 1) + numpy_rng = np.random.default_rng(seed_for_np) + if with_recirculation: + return generate_machine_matrix_with_recirculation( + num_jobs, num_machines, numpy_rng + ).tolist() + + return generate_machine_matrix_without_recirculation( + num_jobs, num_machines, numpy_rng + ).tolist() + + return generator def generate_machine_matrix_with_recirculation( @@ -51,8 +70,8 @@ def generate_machine_matrix_with_recirculation( rng: A numpy random number generator. Returns: - A machine matrix with recirculation with shape (num_machines, - num_jobs). + A machine matrix with recirculation with shape (num_jobs, + num_machines). """ rng = rng or np.random.default_rng() if num_jobs <= 0: @@ -62,7 +81,7 @@ def generate_machine_matrix_with_recirculation( num_machines_is_correct = False while not num_machines_is_correct: machine_matrix: np.ndarray = rng.integers( - 0, num_machines, size=(num_machines, num_jobs), dtype=np.int32 + 0, num_machines, size=(num_jobs, num_machines), dtype=np.int32 ) num_machines_is_correct = ( len(np.unique(machine_matrix)) == num_machines @@ -100,31 +119,3 @@ def generate_machine_matrix_without_recirculation( # Shuffle the columns: machine_matrix = np.apply_along_axis(rng.permutation, 1, machine_matrix) return machine_matrix - - -if __name__ == "__main__": - - NUM_JOBS = 3 - NUM_MACHINES = 3 - DURATION_RANGE = (1, 10) - - duration_matrix = generate_duration_matrix( - num_jobs=NUM_JOBS, - num_machines=NUM_MACHINES, - duration_range=DURATION_RANGE, - ) - print(duration_matrix) - - machine_matrix_with_recirculation = ( - generate_machine_matrix_with_recirculation( - num_jobs=NUM_JOBS, num_machines=NUM_MACHINES - ) - ) - print(machine_matrix_with_recirculation) - - machine_matrix_without_recirculation = ( - generate_machine_matrix_without_recirculation( - num_jobs=NUM_JOBS, num_machines=NUM_MACHINES - ) - ) - print(machine_matrix_without_recirculation) diff --git a/job_shop_lib/generation/_modular_instance_generator.py b/job_shop_lib/generation/_modular_instance_generator.py new file mode 100644 index 00000000..d188f026 --- /dev/null +++ b/job_shop_lib/generation/_modular_instance_generator.py @@ -0,0 +1,116 @@ +from collections.abc import Callable +from typing import Generator +import random + +from job_shop_lib import JobShopInstance + + +def modular_instance_generator( + machine_matrix_creator: Callable[ + [random.Random], list[list[list[int]]] | list[list[int]] + ], + duration_matrix_creator: Callable[ + [list[list[list[int]]] | list[list[int]], random.Random], + list[list[int]], + ], + *, + name_creator: Callable[[int], str] = lambda x: f"generated_instance_{x}", + release_dates_matrix_creator: ( + Callable[ + [list[list[int]], random.Random], + list[list[int]], + ] + | None + ) = None, + deadlines_matrix_creator: ( + Callable[[list[list[int]], random.Random], list[list[int | None]]] + | None + ) = None, + due_dates_matrix_creator: ( + Callable[[list[list[int]], random.Random], list[list[int | None]]] + | None + ) = None, + seed: int | None = None, +) -> Generator[JobShopInstance, None, None]: + """Creates a generator function that produces job shop instances using + the provided components. + + Args: + machine_matrix_creator: + A callable that creates a machine matrix. + duration_matrix_creator: + A callable that creates a duration matrix. + name_creator: + A callable that generates unique names for instances. + release_dates_matrix_creator: + An optional callable that generates release dates for jobs. + deadlines_matrix_creator: + An optional callable that generates deadlines for jobs. + due_dates_matrix_creator: + An optional callable that generates due dates for jobs. + seed: + An optional seed for random number generation. + + Yields: + JobShopInstance: + A generated job shop instance created using the generated matrices. + + Example: + + >>> from job_shop_lib.generation import ( + ... get_default_machine_matrix_creator, + ... get_default_duration_matrix_creator, + ... modular_instance_generator, + ... ) + >>> machine_matrix_gen = get_default_machine_matrix_creator( + ... size_selector=lambda rng: (3, 3), + ... with_recirculation=False, + ... ) + >>> duration_matrix_gen = get_default_duration_matrix_creator( + ... duration_range=(1, 10), + ... ) + >>> instance_gen = modular_instance_generator( + ... machine_matrix_creator=machine_matrix_gen, + ... duration_matrix_creator=duration_matrix_gen, + ... seed=42, + ... ) + >>> instance = next(instance_gen) + >>> print(instance) + JobShopInstance(name=generated_instance_0, num_jobs=3, num_machines=3) + >>> print(instance.duration_matrix_array) + [[ 5. 6. 4.] + [ 5. 7. 10.] + [ 9. 9. 5.]] + + .. versionadded:: 1.7.0 + """ + rng = random.Random(seed) + i = 0 + while True: + machine_matrix = machine_matrix_creator(rng) + duration_matrix = duration_matrix_creator(machine_matrix, rng) + release_dates = ( + release_dates_matrix_creator(duration_matrix, rng) + if release_dates_matrix_creator + else None + ) + deadlines = ( + deadlines_matrix_creator(duration_matrix, rng) + if deadlines_matrix_creator + else None + ) + due_dates = ( + due_dates_matrix_creator(duration_matrix, rng) + if due_dates_matrix_creator + else None + ) + instance = JobShopInstance.from_matrices( + duration_matrix, + machine_matrix, + name=name_creator(i), + release_dates_matrix=release_dates, + deadlines_matrix=deadlines, + due_dates_matrix=due_dates, + ) + i += 1 + yield instance diff --git a/job_shop_lib/generation/_release_date_matrix.py b/job_shop_lib/generation/_release_date_matrix.py new file mode 100644 index 00000000..58984216 --- /dev/null +++ b/job_shop_lib/generation/_release_date_matrix.py @@ -0,0 +1,160 @@ +from typing import Sequence, Callable +import random + +from job_shop_lib.exceptions import ValidationError + + +ReleaseDateStrategy = Callable[[random.Random, int], int] + + +def create_release_dates_matrix( + duration_matrix: list[list[int]], + strategy: ReleaseDateStrategy | None = None, + rng: random.Random | None = None, +) -> list[list[int]]: + """Generate per-operation release dates for ragged job durations. + + Args: + duration_matrix: + Ragged list of per-job operation durations. + strategy: + Callable implementing the release date policy. If ``None`` + a default mixed strategy (alpha=0.7, beta=0.3) is built using the + computed horizon proxy. + rng: + Optional numpy random generator (one will be created if omitted). + + Returns: + A ragged list mirroring ``duration_matrix`` structure with per- + operation release dates. + + .. versionadded:: 1.7.0 + """ + rng = rng or random.Random() + + num_jobs = len(duration_matrix) + if num_jobs == 0: + return [] + + if strategy is None: + horizon_proxy = compute_horizon_proxy(duration_matrix) + strategy = get_mixed_release_date_strategy(0.7, 0.3, horizon_proxy) + + release_dates_matrix: list[list[int]] = [] + for job_durations in duration_matrix: + job_release_dates: list[int] = [] + cumulative_previous_duration = 0 + for duration_value in job_durations: + release_date_value = strategy(rng, cumulative_previous_duration) + job_release_dates.append(release_date_value) + cumulative_previous_duration += int(duration_value) + release_dates_matrix.append(job_release_dates) + + return release_dates_matrix + + +def compute_horizon_proxy(duration_matrix: Sequence[Sequence[int]]) -> int: + """Compute the horizon proxy used previously for the mixed strategy. + + It is defined as: round(total_duration / avg_operations_per_job) + with protections against division by zero. + + Args: + duration_matrix: + Ragged list of per-job operation durations. + + Returns: + The computed horizon proxy. + + .. seealso:: + :meth:`job_shop_lib.JobShopInstance.duration_matrix` + + .. versionadded:: 1.7.0 + """ + num_jobs = len(duration_matrix) + if num_jobs == 0: + return 0 + total_duration = sum(sum(job) for job in duration_matrix) + total_operations = sum(len(job) for job in duration_matrix) + avg_ops_per_job = total_operations / max(1, num_jobs) + return round(total_duration / max(1, avg_ops_per_job)) + + +def get_independent_release_date_strategy( + max_release_time: int, +) -> ReleaseDateStrategy: + """Factory for an independent (pure random) release date strategy. + + The release date is drawn uniformly at random in the interval + ``[0, max_release_time]``. + + Args: + max_release_time: + Inclusive upper bound for the random value. + + Returns: + A callable implementing the independent release date strategy. + + .. versionadded:: 1.7.0 + """ + if max_release_time < 0: + raise ValidationError("'max_release_time' must be >= 0.") + + def _strategy(rng: random.Random, unused_cumulative_prev: int) -> int: + return int(rng.randint(0, max_release_time)) + + return _strategy + + +def get_cumulative_release_date_strategy( + maximum_slack: int = 0, +) -> ReleaseDateStrategy: + """Factory for a cumulative strategy allowing forward slack. + + The release date is the cumulative previous processing time plus a + random slack in ``[0, maximum_slack]``. + + Args: + maximum_slack: + Non-negative integer defining the maximum forward slack to add. + + Returns: + A callable implementing the cumulative release date strategy. + """ + if maximum_slack < 0: + raise ValidationError("'maximum_slack' must be >= 0.") + + def _strategy(rng: random.Random, cumulative_prev: int) -> int: + return cumulative_prev + rng.randint(0, maximum_slack) + + return _strategy + + +def get_mixed_release_date_strategy( + alpha: float, + beta: float, + horizon_proxy: int, +) -> ReleaseDateStrategy: + """Factory for the mixed heuristic strategy. + + release_date = alpha * cumulative_previous + U(0, beta * horizon_proxy) + + Args: + alpha: + Weight for the proportional cumulative component. + beta: + Weight for the random component upper bound. + horizon_proxy: + Non-negative proxy for the time horizon (e.g. derived + from durations to scale the random component consistently). + """ + if horizon_proxy < 0: + raise ValidationError("'horizon_proxy' must be >= 0.") + + random_component_upper = round(beta * horizon_proxy) + + def _strategy(rng: random.Random, cumulative_prev: int) -> int: + random_component = rng.randint(0, random_component_upper) + return round(alpha * cumulative_prev) + random_component + + return _strategy diff --git a/job_shop_lib/generation/_size_selectors.py b/job_shop_lib/generation/_size_selectors.py new file mode 100644 index 00000000..38ce99f2 --- /dev/null +++ b/job_shop_lib/generation/_size_selectors.py @@ -0,0 +1,58 @@ +import random + + +def range_size_selector( + rng: random.Random, + num_jobs_range: tuple[int, int] = (10, 20), + num_machines_range: tuple[int, int] = (5, 10), + allow_less_jobs_than_machines: bool = True, +) -> tuple[int, int]: + """Selects the number of jobs and machines based on the provided ranges + and constraints. + + Args: + rng: + A ``random.Random`` instance. + num_jobs_range: + A tuple specifying the inclusive range for the number of jobs. + num_machines_range: + A tuple specifying the inclusive range for the number of machines. + allow_less_jobs_than_machines: + If ``False``, ensures that the number of jobs is not less than the + number of machines. + + Returns: + A tuple containing the selected number of jobs and machines. + """ + num_jobs = rng.randint(num_jobs_range[0], num_jobs_range[1]) + + min_num_machines, max_num_machines = num_machines_range + if not allow_less_jobs_than_machines: + # Cap the maximum machines to the sampled number of jobs. + max_num_machines = min(max_num_machines, num_jobs) + # If min > capped max, collapse interval so we return a valid value + # (e.g. jobs=3, range=(5,10)). + if min_num_machines > max_num_machines: + min_num_machines = max_num_machines + num_machines = rng.randint(min_num_machines, max_num_machines) + + return num_jobs, num_machines + + +def choice_size_selector( + rng: random.Random, + options: list[tuple[int, int]], +) -> tuple[int, int]: + """Selects the number of jobs and machines from a list of options. + + Args: + rng: + A ``random.Random`` instance. + options: + A list of tuples, where each tuple contains a pair of integers + representing the number of jobs and machines. + + Returns: + A tuple containing the selected number of jobs and machines. + """ + return rng.choice(options) diff --git a/tests/benchmarking/test_load_benchmark.py b/tests/benchmarking/test_load_benchmark.py index 29d0a063..c022b88a 100644 --- a/tests/benchmarking/test_load_benchmark.py +++ b/tests/benchmarking/test_load_benchmark.py @@ -2,6 +2,7 @@ from job_shop_lib.benchmarking import ( load_all_benchmark_instances, load_benchmark_instance, + load_benchmark_group, ) from job_shop_lib.constraint_programming import ORToolsSolver @@ -27,3 +28,24 @@ def test_load_all_benchmark_instances(): ft06 = instances["ft06"] solution = ORToolsSolver().solve(ft06) assert solution.makespan() == ft06.metadata["optimum"] == 55 + + +def test_load_benchmark_group_la(): + la_instances = load_benchmark_group("la") + assert len(la_instances) == 40 # la01-la40 + assert all(isinstance(inst, JobShopInstance) for inst in la_instances) + assert all(inst.name.startswith("la") for inst in la_instances) + # Cached call returns same list object (functools.cache behavior) + assert load_benchmark_group("la") is la_instances + + +def test_load_benchmark_group_ft(): + ft_instances = load_benchmark_group("ft") + assert len(ft_instances) == 3 # ft06, ft10, ft20 + names = {inst.name for inst in ft_instances} + assert names == {"ft06", "ft10", "ft20"} + + +def test_load_benchmark_group_nonexistent(): + none = load_benchmark_group("zz") + assert none == [] diff --git a/tests/generation/test_duration_matrix.py b/tests/generation/test_duration_matrix.py new file mode 100644 index 00000000..69832380 --- /dev/null +++ b/tests/generation/test_duration_matrix.py @@ -0,0 +1,75 @@ +import random + +import numpy as np +import pytest + +from job_shop_lib.generation import ( + generate_duration_matrix, + get_default_duration_matrix_creator, +) +from job_shop_lib.exceptions import ValidationError + + +def test_generate_duration_matrix_shape_and_range(): + rng = np.random.default_rng(123) + mat = generate_duration_matrix(4, 5, (2, 7), rng) + assert mat.shape == (4, 5) + assert mat.min() >= 2 and mat.max() <= 7 + + +def test_generate_duration_matrix_invalid_range(): + with pytest.raises(ValidationError): + generate_duration_matrix(2, 2, (5, 4)) # invalid range + + +def test_generate_duration_matrix_invalid_jobs(): + with pytest.raises(ValidationError): + generate_duration_matrix(0, 2, (1, 5)) + + +def test_generate_duration_matrix_invalid_machines(): + with pytest.raises(ValidationError): + generate_duration_matrix(2, 0, (1, 5)) + + +def test_generate_duration_matrix_rng_determinism(): + seed = 999 + rng1 = np.random.default_rng(seed) + rng2 = np.random.default_rng(seed) + mat1 = generate_duration_matrix(3, 3, (1, 9), rng1) + mat2 = generate_duration_matrix(3, 3, (1, 9), rng2) + assert np.array_equal(mat1, mat2) + + +def test_get_default_duration_matrix_creator_basic(): + seed = 42 + python_rng = random.Random(seed) + creator = get_default_duration_matrix_creator((3, 5)) + machine_matrix = [[0, 1, 2], [2, 1, 0]] # shape (jobs=2, machines=3) + durations = creator(machine_matrix, python_rng) + assert len(durations) == 2 + assert all(len(row) == 3 for row in durations) + flat = [d for row in durations for d in row] + assert all(3 <= v <= 5 for v in flat) + + +def test_get_default_duration_matrix_creator_different_rngs_differ(): + seed1 = 7 + seed2 = 8 + creator = get_default_duration_matrix_creator((1, 10)) + mm = [[0, 1], [1, 0], [0, 1]] # (3,2) + out1 = creator(mm, random.Random(seed1)) + out2 = creator(mm, random.Random(seed2)) + # Extremely small chance they are equal; treated as different expectation + assert out1 != out2 + + +def test_get_default_duration_matrix_creator_deterministic_with_same_seed(): + seed = 101 + creator = get_default_duration_matrix_creator((4, 9)) + mm = [[0, 1], [1, 0]] + rng1 = random.Random(seed) + rng2 = random.Random(seed) + out1 = creator(mm, rng1) + out2 = creator(mm, rng2) + assert out1 == out2 diff --git a/tests/generation/test_generation_utils.py b/tests/generation/test_generation_utils.py deleted file mode 100644 index 8b685142..00000000 --- a/tests/generation/test_generation_utils.py +++ /dev/null @@ -1,177 +0,0 @@ -import pytest -import numpy as np - -from job_shop_lib.exceptions import ValidationError -from job_shop_lib.generation import ( - generate_duration_matrix, - generate_machine_matrix_with_recirculation, - generate_machine_matrix_without_recirculation, -) - - -@pytest.fixture -def basic_params(): - return {"num_jobs": 3, "num_machines": 3, "duration_range": (1, 10)} - - -def test_generate_duration_matrix_shape(basic_params): - """Test if the generated duration matrix has the correct shape.""" - duration_matrix = generate_duration_matrix(**basic_params) - assert len(duration_matrix) == basic_params["num_jobs"] - assert all( - len(row) == basic_params["num_machines"] for row in duration_matrix - ) - - -def test_generate_duration_matrix_range(basic_params): - """Test if all values in duration matrix are within the specified range.""" - duration_matrix = generate_duration_matrix(**basic_params) - min_val, max_val = basic_params["duration_range"] - - for row in duration_matrix: - assert all(min_val <= val <= max_val for val in row) - - -def test_generate_duration_matrix_different_seeds(): - """Test if different seeds generate different matrices.""" - params = {"num_jobs": 3, "num_machines": 3, "duration_range": (1, 10)} - - np.random.seed(42) - matrix1 = generate_duration_matrix(**params) - np.random.seed(43) - matrix2 = generate_duration_matrix(**params) - - assert not np.array_equal(matrix1, matrix2) - - -def test_generate_machine_matrix_with_recirculation_shape(basic_params): - """Test if the generated machine matrix with recirculation has correct - shape.""" - matrix = generate_machine_matrix_with_recirculation( - basic_params["num_jobs"], basic_params["num_machines"] - ) - assert len(matrix) == basic_params["num_machines"] - assert all(len(row) == basic_params["num_jobs"] for row in matrix) - - -def test_generate_machine_matrix_with_recirculation_values(basic_params): - """Test if all machines are used in the matrix with recirculation.""" - matrix = generate_machine_matrix_with_recirculation( - basic_params["num_jobs"], basic_params["num_machines"] - ) - unique_machines = set(val for row in matrix for val in row) - assert len(unique_machines) == basic_params["num_machines"] - assert all( - 0 <= val < basic_params["num_machines"] - for row in matrix - for val in row - ) - - -def test_generate_machine_matrix_without_recirculation_shape(basic_params): - """Test if the generated machine matrix without recirculation has correct - shape.""" - matrix = generate_machine_matrix_without_recirculation( - basic_params["num_jobs"], basic_params["num_machines"] - ) - assert len(matrix) == basic_params["num_jobs"] - assert all(len(row) == basic_params["num_machines"] for row in matrix) - - -def test_generate_machine_matrix_without_recirculation_values(basic_params): - """Test if each job uses each machine exactly once without - recirculation.""" - matrix = generate_machine_matrix_without_recirculation( - basic_params["num_jobs"], basic_params["num_machines"] - ) - - for row in matrix: - # Check if each machine appears exactly once in each job - unique_machines = set(row) - assert len(unique_machines) == basic_params["num_machines"] - assert all(0 <= val < basic_params["num_machines"] for val in row) - - -def test_edge_cases(): - """Test edge cases with minimum values.""" - min_params = {"num_jobs": 1, "num_machines": 1, "duration_range": (1, 1)} - - # Test duration matrix - duration_matrix = generate_duration_matrix(**min_params) # type: ignore - assert len(duration_matrix) == 1 - assert len(duration_matrix[0]) == 1 - assert duration_matrix[0][0] == 1 - - # Test machine matrix with recirculation - matrix_with_recirc = generate_machine_matrix_with_recirculation(1, 1) - assert len(matrix_with_recirc) == 1 - assert len(matrix_with_recirc[0]) == 1 - assert matrix_with_recirc[0][0] == 0 - - # Test machine matrix without recirculation - matrix_without_recirc = generate_machine_matrix_without_recirculation(1, 1) - assert len(matrix_without_recirc) == 1 - assert len(matrix_without_recirc[0]) == 1 - assert matrix_without_recirc[0][0] == 0 - - -def test_invalid_inputs(): - """Test if functions handle invalid inputs appropriately.""" - - with pytest.raises(ValidationError): - generate_duration_matrix(0, 3, (1, 10)) - - with pytest.raises(ValidationError): - generate_duration_matrix(3, 0, (10, 1)) - - with pytest.raises(ValidationError): - generate_duration_matrix(3, 0, (1, 10)) - - with pytest.raises(ValidationError): - generate_duration_matrix(3, 3, (10, 1)) - - with pytest.raises(ValidationError): - generate_machine_matrix_with_recirculation(0, 3) - - with pytest.raises(ValidationError): - generate_machine_matrix_with_recirculation(3, 0) - - -def test_rng_determinism(): - """Tests that passing a custom RNG with a fixed seed produces deterministic - results.""" - import numpy as np - from job_shop_lib.generation import ( - generate_duration_matrix, - generate_machine_matrix_with_recirculation, - generate_machine_matrix_without_recirculation, - ) - - params = {"num_jobs": 3, "num_machines": 3, "duration_range": (1, 10)} - seed = 12345 - rng1 = np.random.default_rng(seed) - rng2 = np.random.default_rng(seed) - # Duration matrix - mat1 = generate_duration_matrix(**params, rng=rng1) - mat2 = generate_duration_matrix(**params, rng=rng2) - assert np.array_equal(mat1, mat2) - # Machine matrix with recirculation - rng1 = np.random.default_rng(seed) - rng2 = np.random.default_rng(seed) - mat1 = generate_machine_matrix_with_recirculation( - params["num_jobs"], params["num_machines"], rng=rng1 - ) - mat2 = generate_machine_matrix_with_recirculation( - params["num_jobs"], params["num_machines"], rng=rng2 - ) - assert np.array_equal(mat1, mat2) - # Machine matrix without recirculation - rng1 = np.random.default_rng(seed) - rng2 = np.random.default_rng(seed) - mat1 = generate_machine_matrix_without_recirculation( - params["num_jobs"], params["num_machines"], rng=rng1 - ) - mat2 = generate_machine_matrix_without_recirculation( - params["num_jobs"], params["num_machines"], rng=rng2 - ) - assert np.array_equal(mat1, mat2) diff --git a/tests/generation/test_machine_matrix.py b/tests/generation/test_machine_matrix.py new file mode 100644 index 00000000..1245e0cc --- /dev/null +++ b/tests/generation/test_machine_matrix.py @@ -0,0 +1,109 @@ +import random + +import numpy as np +import pytest + +from job_shop_lib.generation import ( + generate_machine_matrix_with_recirculation, + generate_machine_matrix_without_recirculation, + get_default_machine_matrix_creator, +) +from job_shop_lib.exceptions import ValidationError + + +def test_machine_matrix_with_recirculation_shape_and_coverage(): + num_jobs = 6 + num_machines = 4 + rng = np.random.default_rng(123) + mat = generate_machine_matrix_with_recirculation( + num_jobs, num_machines, rng + ) + # Shape (num_jobs, num_machines) + assert mat.shape == (num_jobs, num_machines) + # All machines used at least once + assert set(mat.flatten()) == set(range(num_machines)) + + +def test_machine_matrix_with_recirculation_validation_errors(): + with pytest.raises(ValidationError): + generate_machine_matrix_with_recirculation(0, 3) + with pytest.raises(ValidationError): + generate_machine_matrix_with_recirculation(3, 0) + + +def test_machine_matrix_without_recirculation_validation_errors(): + with pytest.raises(ValidationError): + generate_machine_matrix_without_recirculation(0, 3) + with pytest.raises(ValidationError): + generate_machine_matrix_without_recirculation(3, 0) + + +def test_machine_matrix_with_recirculation_determinism(): + seed = 9876 + rng1 = np.random.default_rng(seed) + rng2 = np.random.default_rng(seed) + m1 = generate_machine_matrix_with_recirculation(5, 3, rng1) + m2 = generate_machine_matrix_with_recirculation(5, 3, rng2) + assert np.array_equal(m1, m2) + + +def test_machine_matrix_without_recirculation_shape_and_permutation(): + num_jobs = 5 + num_machines = 3 + rng = np.random.default_rng(321) + mat = generate_machine_matrix_without_recirculation( + num_jobs, num_machines, rng + ) + # Shape (num_jobs, num_machines) + assert mat.shape == (num_jobs, num_machines) + # Each row is a permutation of all machines + expected = set(range(num_machines)) + for row in mat: + assert set(row) == expected + # no duplicates inside a row + assert len(set(row)) == num_machines + + +def test_machine_matrix_without_recirculation_determinism(): + seed = 444 + rng1 = np.random.default_rng(seed) + rng2 = np.random.default_rng(seed) + m1 = generate_machine_matrix_without_recirculation(4, 4, rng1) + m2 = generate_machine_matrix_without_recirculation(4, 4, rng2) + assert np.array_equal(m1, m2) + + +def test_get_default_machine_matrix_creator_with_recirculation(): + creator = get_default_machine_matrix_creator( + size_selector=lambda _: (5, 3) + ) + seed = 2024 + out1 = creator(random.Random(seed)) + out2 = creator(random.Random(seed)) + # With recirculation underlying shape is (num_jobs, num_machines) => (5,3) + assert len(out1) == 5 + assert all(len(row) == 3 for row in out1) + assert out1 == out2 # deterministic with same seed + # Coverage check + assert {m for row in out1 for m in row} == {0, 1, 2} + + +def test_get_default_machine_matrix_creator_without_recirculation(): + creator = get_default_machine_matrix_creator( + size_selector=lambda rng: (4, 2), with_recirculation=False + ) + seed = 55 + out1 = creator(random.Random(seed)) + out2 = creator(random.Random(seed)) + # Without recirculation shape is (num_jobs, num_machines) => (4,2) + assert len(out1) == 4 + assert all(len(row) == 2 for row in out1) + assert out1 == out2 + # Each row should contain both machines 0 and 1 exactly once + for row in out1: + assert set(row) == {0, 1} + assert len(row) == 2 + + +if __name__ == "__main__": + pytest.main() diff --git a/tests/generation/test_modular_instance_generator.py b/tests/generation/test_modular_instance_generator.py new file mode 100644 index 00000000..4b95d6c2 --- /dev/null +++ b/tests/generation/test_modular_instance_generator.py @@ -0,0 +1,35 @@ +from job_shop_lib.generation import ( + get_default_machine_matrix_creator, + get_default_duration_matrix_creator, + modular_instance_generator, + range_size_selector, +) + + +def test_reproducibility(): + machine_matrix_gen = get_default_machine_matrix_creator( + size_selector=range_size_selector, + with_recirculation=False, + ) + duration_matrix_gen = get_default_duration_matrix_creator( + duration_range=(1, 10), + ) + instance_gen1 = modular_instance_generator( + machine_matrix_creator=machine_matrix_gen, + duration_matrix_creator=duration_matrix_gen, + seed=42, + ) + instance_gen2 = modular_instance_generator( + machine_matrix_creator=machine_matrix_gen, + duration_matrix_creator=duration_matrix_gen, + seed=42, + ) + + instance1 = next(instance_gen1) + instance2 = next(instance_gen2) + assert instance1 == instance2 + instance1 = next(instance_gen1) + instance2 = next(instance_gen2) + assert instance1 == instance2 + instance1 = next(instance_gen1) + instance2 = next(instance_gen2) diff --git a/tests/generation/test_release_date_matrix.py b/tests/generation/test_release_date_matrix.py new file mode 100644 index 00000000..a9906a65 --- /dev/null +++ b/tests/generation/test_release_date_matrix.py @@ -0,0 +1,175 @@ +import random + +import pytest + +from job_shop_lib.exceptions import ValidationError +from job_shop_lib.generation import ( + create_release_dates_matrix, + compute_horizon_proxy, + get_independent_release_date_strategy, + get_cumulative_release_date_strategy, + get_mixed_release_date_strategy, +) + + +def test_independent_strategy_basic(): + strategy = get_independent_release_date_strategy(5) + rng = random.Random(123) + values = [strategy(rng, 0) for _ in range(10)] + assert all(0 <= v <= 5 for v in values) + # Determinism + rng2 = random.Random(123) + values2 = [strategy(rng2, 0) for _ in range(10)] + assert values == values2 + + +def test_independent_strategy_invalid(): + with pytest.raises(ValidationError): + get_independent_release_date_strategy(-1) + + +def test_cumulative_strategy_no_slack(): + strat = get_cumulative_release_date_strategy(0) + rng = random.Random(0) + # Should return cumulative prev unchanged (non-negative) when slack=0 + assert strat(rng, 10) == 10 + + +def test_cumulative_strategy_with_slack(): + strat = get_cumulative_release_date_strategy(5) + rng = random.Random(1) + # Generate multiple to sample slack branch + results = {strat(rng, 20) for _ in range(20)} + # All results >= 20 and <= 25 (20 + max slack) + assert all(20 <= r <= 25 for r in results) + # At least one value different from 20 to ensure slack applied + assert any(r != 20 for r in results) + + +def test_cumulative_strategy_invalid(): + with pytest.raises(ValidationError): + get_cumulative_release_date_strategy(-2) + + +def test_mixed_strategy_basic(): + horizon = 50 + strat = get_mixed_release_date_strategy( + alpha=0.5, beta=0.4, horizon_proxy=horizon + ) + rng = random.Random(2) + v = strat(rng, 40) # = 0.5*40 + random in [0, 0.4*50] + assert 20 <= v <= 20 + int(0.4 * horizon) + + +def test_mixed_strategy_zero_random_component(): + # horizon_proxy=0 => random_component_upper=0 => no random addition + strat = get_mixed_release_date_strategy( + alpha=0.3, beta=0.9, horizon_proxy=0 + ) + rng = random.Random(0) + assert strat(rng, 100) == int(0.3 * 100) + + +def test_mixed_strategy_invalid_params(): + with pytest.raises(ValidationError): + get_mixed_release_date_strategy(alpha=0.5, beta=0.5, horizon_proxy=-1) + + +def test_compute_horizon_proxy_empty(): + assert compute_horizon_proxy([]) == 0 + + +def test_compute_horizon_proxy_non_empty(): + # total_duration =3+2+4 =9; ops=3; jobs=2; avg_ops_per_job=3/2=1.5; 9/1.5=6 + dm = [[3, 2], [4]] + assert compute_horizon_proxy(dm) == 6 + + +def test_create_release_dates_matrix_with_strategy(): + dm = [[3, 2], [4, 1]] + strat = get_cumulative_release_date_strategy(maximum_slack=0) + rng = random.Random(0) + rd = create_release_dates_matrix(dm, strategy=strat, rng=rng) + # Cumulative release dates should equal cumulative previous durations + assert rd[0][0] == 0 # first op + assert rd[0][1] == 3 # previous duration + assert rd[1][0] == 0 + assert rd[1][1] == 4 + + +def test_create_release_dates_matrix_default_strategy(): + dm = [[5, 1, 2], [4]] + rng = random.Random(42) + rd = create_release_dates_matrix(dm, rng=rng) + # Structure preserved + assert len(rd) == len(dm) + assert all(len(r1) == len(r2) for r1, r2 in zip(rd, dm)) + # All non-negative + assert all(all(v >= 0 for v in row) for row in rd) + + +def test_create_release_dates_matrix_empty(): + assert not create_release_dates_matrix([], rng=random.Random(0)) + + +def test_compute_horizon_proxy_all_empty_jobs(): + # num_jobs > 0 but zero operations overall -> denominator protection path + dm: list[list[int]] = [[], [], []] + assert compute_horizon_proxy(dm) == 0 + + +def test_compute_horizon_proxy_some_empty_jobs(): + # total_duration = 5+1 =6; total_ops=3; jobs=3; avg_ops_per_job=3/3=1; 6/1=6 + dm = [[2, 3], [], [1]] + assert compute_horizon_proxy(dm) == 6 + + +def test_create_release_dates_matrix_with_empty_job_and_custom_strategy(): + dm = [[4, 2], [], [3]] + # Use cumulative strategy with slack to exercise slack branch too + strat = get_cumulative_release_date_strategy(maximum_slack=2) + rng = random.Random(10) + rdm = create_release_dates_matrix(dm, strategy=strat, rng=rng) + assert len(rdm) == 3 + assert rdm[1] == [] # empty job preserved + # Durations accumulate + forward slack (release date >= cumulative prev) + cum_prev = 0 + for dur, rel in zip(dm[0], rdm[0]): + assert rel >= cum_prev + # slack bounded by 2 + assert rel <= cum_prev + 2 + cum_prev += dur + + +def test_mixed_release_date_strategy_determinism_and_bounds(): + horizon = 120 + alpha = 0.6 + beta = 0.25 + strat = get_mixed_release_date_strategy(alpha, beta, horizon) + rng1 = random.Random(123) + rng2 = random.Random(123) + vals1 = [strat(rng1, c) for c in range(0, 200, 25)] + vals2 = [strat(rng2, c) for c in range(0, 200, 25)] + assert vals1 == vals2 # deterministic + # Bounds check + upper_rand = int(beta * horizon) + for c, v in zip(range(0, 200, 25), vals1): + assert int(alpha * c) <= v <= int(alpha * c) + upper_rand + + +def test_independent_release_date_strategy_variability(): + strat = get_independent_release_date_strategy(3) + rng = random.Random(7) + values = {strat(rng, 0) for _ in range(30)} + # Expect to have seen all numbers 0..3 in 30 draws with high probability + assert values == {0, 1, 2, 3} + + +def test_create_release_dates_matrix_default_strategy_coverage_zero_horizon_case(): + # Provide durations such that horizon_proxy becomes 0 when averaged + # Use empty jobs only -> already covered; we now mix empty and zero durations + dm = [[], [0, 0], []] + rng = random.Random(5) + rd = create_release_dates_matrix(dm, rng=rng) + assert rd[0] == [] and rd[2] == [] + assert rd[1][0] == 0 and rd[1][1] >= 0 # second op non-negative diff --git a/tests/generation/test_size_selectors.py b/tests/generation/test_size_selectors.py new file mode 100644 index 00000000..ed89b6ab --- /dev/null +++ b/tests/generation/test_size_selectors.py @@ -0,0 +1,82 @@ +import random + +import pytest + +from job_shop_lib.generation import range_size_selector, choice_size_selector + + +def test_range_size_selector_defaults(): + rng = random.Random(123) + jobs, machines = range_size_selector(rng) + assert 10 <= jobs <= 20 + assert 5 <= machines <= 10 + + +def test_range_size_selector_enforce_jobs_ge_machines_basic(): + rng = random.Random(0) + jobs, machines = range_size_selector( + rng, + num_jobs_range=(5, 5), + num_machines_range=(1, 10), + allow_less_jobs_than_machines=False, + ) + assert jobs == 5 + assert machines <= jobs + + +def test_range_size_selector_min_greater_than_jobs_range_collapses(): + # num_machines_range minimum > possible jobs; should collapse to jobs. + rng = random.Random(1) + jobs, machines = range_size_selector( + rng, + num_jobs_range=(3, 3), + num_machines_range=(5, 10), + allow_less_jobs_than_machines=False, + ) + assert jobs == 3 + # With the fix machines should now be capped at 3 instead of > jobs. + assert machines == 3 + + +def test_range_size_selector_determinism(): + seed = 42 + rng1 = random.Random(seed) + rng2 = random.Random(seed) + assert range_size_selector( + rng1, (8, 12), (3, 6), True + ) == range_size_selector(rng2, (8, 12), (3, 6), True) + + +def test_choice_size_selector_returns_option(): + rng = random.Random(99) + options = [(1, 2), (3, 4), (5, 6)] + assert choice_size_selector(rng, options) in options + + +def test_choice_size_selector_deterministic_seed(): + seed = 77 + options = [(1, 2), (3, 4), (5, 6)] + rng1 = random.Random(seed) + rng2 = random.Random(seed) + assert choice_size_selector(rng1, options) == choice_size_selector( + rng2, options + ) + + +def test_choice_size_selector_single_option(): + rng = random.Random(5) + assert choice_size_selector(rng, [(7, 8)]) == (7, 8) + + +@pytest.mark.parametrize( + "options", + [ + [(1, 1)], + [(2, 3), (4, 5), (6, 7)], + [(10, 20), (30, 40)], + ], +) +def test_choice_size_selector_valid_outputs(options): + rng = random.Random(1234) + for _ in range(20): + assert choice_size_selector(rng, options) in options diff --git a/tests/test_job_shop_instance.py b/tests/test_job_shop_instance.py index f9815dc9..6af2a6c7 100644 --- a/tests/test_job_shop_instance.py +++ b/tests/test_job_shop_instance.py @@ -32,7 +32,7 @@ def test_set_operation_attributes(): def test_from_matrices(job_shop_instance_with_extras: JobShopInstance): - duration_matrix = job_shop_instance_with_extras.durations_matrix + duration_matrix = job_shop_instance_with_extras.duration_matrix machines_matrix = job_shop_instance_with_extras.machines_matrix release_dates_matrix = job_shop_instance_with_extras.release_dates_matrix deadlines_matrix = job_shop_instance_with_extras.deadlines_matrix @@ -50,7 +50,7 @@ def test_from_matrices(job_shop_instance_with_extras: JobShopInstance): metadata=metadata, ) - assert new_instance.durations_matrix == duration_matrix + assert new_instance.duration_matrix == duration_matrix assert new_instance.machines_matrix == machines_matrix assert new_instance.release_dates_matrix == release_dates_matrix assert new_instance.deadlines_matrix == deadlines_matrix @@ -83,7 +83,11 @@ def test_durations_matrix(job_shop_instance: JobShopInstance): [10, 20], [15, 10], ] - assert job_shop_instance.durations_matrix == expected_matrix + assert job_shop_instance.duration_matrix == expected_matrix + + # check that "durations_matrix" is deprecated + with pytest.warns(DeprecationWarning): + assert job_shop_instance.durations_matrix == expected_matrix def test_machines_matrix(job_shop_instance: JobShopInstance): @@ -197,10 +201,15 @@ def test_duration_matrix_array(): [Operation(machines=1, duration=3)], ] instance = JobShopInstance(jobs=jobs) - assert str(instance.durations_matrix_array) == str( + assert str(instance.duration_matrix_array) == str( np.array([[1.0, 2.0], [3.0, np.nan]], dtype=np.float32) ) + # check that "durations_matrix_array" is deprecated + with pytest.warns(DeprecationWarning): + assert str(instance.durations_matrix_array) == str( + np.array([[1.0, 2.0], [3.0, np.nan]], dtype=np.float32) + ) def test_release_dates_matrix_array(): jobs = [