Coverage for moptipyapps / prodsched / rop_experiment.py: 33%
67 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-30 03:25 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-30 03:25 +0000
1"""
2A small template for ROP-based experiments.
4This experiment uses the NSGA-II algorithm to optimize Re-Order-Points (ROPs)
5to achieve both a high worst-case fillrate and a low worst-case average stock
6level.
8It is just a preliminary idea.
10In manufacturing systems, the concept of re-order points (ROPs) can be used
11to schedule production.
12The ROP basically provides one limit `X` per product.
13If there are less or equal (`<=`) `X` units of the product in the warehouse,
14a production order is issued so that one new unit is produced.
16The question is how should `X` be set so that we can
181. satisfy as many customer demands as possible directly when they come in,
19 i.e., maximize the fill rate
20 (:attr:`~moptipyapps.prodsched.statistics.Statistics.immediate_rates`,
21 represented by
22 :mod:`~moptipyapps.prodsched.objectives.worst_and_mean_fill_rate`) and
232. have a low total average number of product units sitting around in our
24 warehouse, i.e., minimize the stock level
25 (:attr:`~moptipyapps.prodsched.statistics.Statistics.stock_levels`,
26 represented by
27 :mod:`~moptipyapps.prodsched.objectives.max_stocklevel`).
29Since we have `n=10` products in the Thürer [1] base scenario, we also have
30`10` such ROP values `X`.
32Now how can we know the fill rate and the stock level?
33This is done by simulating the whole system in action.
34We therefore use instances generated using the Thürer-style distributions as
35defined in :mod:`~moptipyapps.prodsched.mfc_generator` [1].
37Our simulations (see :mod:`~moptipyapps.prodsched.simulation`) are not
38randomized.
39Instead, each of them is based on a fixed
40:mod:`~moptipyapps.prodsched.instance`.
41Each :mod:`~moptipyapps.prodsched.instance.Instance` defines exactly when a
42customer :class:`~moptipyapps.prodsched.instance.Demand` comes in and,
43for many time windows, the production time needed by a machine to produce
44one unit of product.
45Of course, all of these values follow the random distributions (Erlang, Gamma,
46with respective parameters) given in Tables 2 and 3 of the original Thürer
47paper [1] and implemented in :mod:`~moptipyapps.prodsched.mfc_generator`.
48But apart from this, they are fixed per instance.
50This means that we can take the same ROP and run the simulation twice for a
51given instance and will get exactly the same results and
52:mod:`~moptipyapps.prodsched.statistics` (fill rates, stock levels, etc.).
54Of course, using a single fixed :mod:`~moptipyapps.prodsched.instance` may
55be misleading.
56Maybe we would think that a certain ROP is very good ... but it is only
57good on the specific instance we tried.
59So as a second step, we here generate 11 instances (via
60:func:`~moptipyapps.prodsched.instances.get_instances`).
61And then we look at the worst fill rate and the worst stock level over all 11
62instances (more or less).
63And we use that to judge whether an ROP is good or not.
65This leaves the question:
66Where do these ROPs come from?
68They come from an optimization process.
69First, we define that ROPs be integer vectors (i.e., from an
70:class:`~moptipy.spaces.intspace.IntSpace`) where each element comes from
71the range `0..63`.
72We sample the initial solutions randomly from that interval
73(via :class:`~moptipy.operators.intspace.op0_random.Op0Random`).
75As unary search operator, we take an existing ROP and, for a number of
76elements, sample a new value normally distributed around it (with standard
77deviation 2.5) but rounded to integer.
78We do this for a binomially distributed number of elements, exactly like the
79bit-string based (1+1) EA would do it, but implemented for integers by
80operator :class:`~moptipy.operators.intspace.op1_mnormal.Op1MNormal`.
82As binary operator, we use uniform crossover, given as
83:class:`~moptipy.operators.intspace.op2_uniform.Op2Uniform`.
84This operator takes two existing solutions and creates a new solution by
85element-wise copying elements of the parents.
86For each element, it randomly decides from which parent solution it should be
87copied.
89As optimization algorithm, we use NSGA-II implemented by class
90:class:`~moptipy.algorithms.mo.nsga2.NSGA2`.
91This is a multi-objective optimization algorithm.
92We do this because we have two goals:
941. Maximize the worst-case fill rates,
952. Minimize the worst-case average stock level.
97Regarding the first objective, we have a small tweak:
98Assume that, over all 11 instances, `PM` be the worst fill rate per product
99(in [0,1], 0 being worst) and `AM` be the worst average fill rate over all
100products (0 worst, 1 best).
101Then our objective value -- subject to minimization -- is
102`(1 - PM) * 100 + (1 - AM)`.
103This is implemented in module
104:mod:`~moptipyapps.prodsched.objectives.worst_and_mean_fill_rate`.
106The objective function minimizing the stock level is implemented in module
107:mod:`~moptipyapps.prodsched.objectives.max_stocklevel`.
109In summary, what we do is this:
1111. The optimization algorithm proposes ROPs, each of which being an integer
112 vector with 10 values (1 value per product).
113 (:class:`~moptipy.spaces.intspace.IntSpace`)
114 These integer vectors are the elements of the search space.
1162. The ROP is evaluated by simulating it 11 times (using 10000 time units per
117 instance, 3000 of which are used for warmup).
118 This is implemented as an :mod:`~moptipy.api.encoding`,
119 :class:`~moptipyapps.prodsched.rop_multisimulation.ROPMultiSimulation`.
1213. This :mod:`~moptipyapps.prodsched.simulation`-based decoding procedure maps
122 the ROP vectors to the solution space. In our case, this solution space are
123 just multi-statistics records, as implemented in module
124 :mod:`~moptipyapps.prodsched.multistatistics`, where a corresponding
125 :mod:`~moptipy.api.space` implementation is also provided.
1274. Each of the 11 simulations is based on one fixed
128 :mod:`~moptipyapps.prodsched.instance`, where all customer demands and
129 machine work times (at certain time intervals) are fixed (but were sampled
130 based on the distributions given in the Thürer paper [1]) using module
131 :mod:`~moptipyapps.prodsched.mfc_generator`.
1335. Each of the 11 simulations has per-product fill rates `PM_i,j`, one average
134 fill rate `AM_i`, one average stock level `SL_i` stored in the
135 :mod:`~moptipyapps.prodsched.multistatistics` records.
1376. As first objective, we use the smallest `PM_i_j` as `PM` and the smallest
138 `AM_i` as `AM` and compute `(1 - AM) * 100 + (1 - PM)` in
139 :mod:`~moptipyapps.prodsched.objectives.worst_and_mean_fill_rate`.
1417. As second objective, we use the largest `SL_i` in
142 :mod:`~moptipyapps.prodsched.objectives.max_stocklevel`.
1448. NSGA-II, given in :mod:`~moptipy.algorithms.mo.nsga2`, maintains a
145 population of solutions which it evaluates like that.
1479. NSGA-II decides which solutions to keep based on the current Pareto front
148 and crowding distance.
15010. The retained solutions are reproduced, either via unary or binary search
151 operators.
15311. The unary search operator changes a random number (binomial distribution)
154 of elements of a ROP vector (via normal distribution).
155 It is given in
156 :class:`~moptipy.operators.intspace.op1_mnormal.Op1MNormal`.
15812. The binary search operator is simple uniform crossover, i.e., fills a
159 new solution with elements of either of the two parent solutions. It is
160 defined in :class:`~moptipy.operators.intspace.op2_uniform.Op2Uniform`.
162The core idea is that we do not use randomized ARENA-like simulation.
163Instead, we use a simulator that is based on deterministic, fully pre-defined
164instances.
165These instances are still randomly generated according to the distributions
166given by Thürer in [1].
167However, they have all values pre-determined.
168This allows us to run a simulation with the same ROP twice and get the exactly
169same result.
170If two different ROPs are simulated, but both of them decide to produce 1 unit
171of product `A` on machine `V` at time unit `T`, then for both of them this
172will take exactly the same amount of time.
174Simulation results are thus less noisy.
176Of course, there is a danger of overfitting.
177This is why we need to use multiple simulations to check a ROP.
178And then we take the worst-case results.
180So this is the idea.
1821. Matthias Thürer, Nuno O. Fernandes, Hermann Lödding, and Mark Stevenson.
183 Material Flow Control in Make-to-Stock Production Systems: An Assessment of
184 Order Generation, Order Release and Production Authorization by Simulation
185 Flexible Services and Manufacturing Journal. 37(1):1-37. March 2025.
186 doi: https://doi.org/10.1007/s10696-024-09532-2
187"""
190import argparse
191from typing import Final
193from moptipy.algorithms.mo.nsga2 import NSGA2
194from moptipy.api.experiment import run_experiment
195from moptipy.api.mo_execution import MOExecution
196from moptipy.mo.problem.weighted_sum import WeightedSum
197from moptipy.operators.intspace.op0_random import Op0Random
198from moptipy.operators.intspace.op1_mnormal import Op1MNormal
199from moptipy.operators.intspace.op2_uniform import Op2Uniform
200from moptipy.spaces.intspace import IntSpace
201from pycommons.io.console import logger
202from pycommons.io.path import Path
203from pycommons.types import check_int_range
205from moptipyapps.prodsched.instance import Instance
206from moptipyapps.prodsched.instances import get_instances
207from moptipyapps.prodsched.multistatistics import MultiStatisticsSpace
208from moptipyapps.prodsched.objectives.max_stocklevel import MaxStockLevel
209from moptipyapps.prodsched.objectives.worst_and_mean_fill_rate import (
210 WorstAndMeanFillRate,
211)
212from moptipyapps.prodsched.rop_multisimulation import ROPMultiSimulation
213from moptipyapps.utils.shared import moptipyapps_argparser
216def run(dest: str, instances: str, n_inst: int, n_runs: int,
217 max_fes: int, ps: int) -> None:
218 """
219 Run the experiment.
221 :param dest: the destination directory
222 :param instances: the directory with the instances
223 :param n_inst: the number of instances
224 :param n_runs: the number of runs
225 :param max_fes: the maximum FEs
226 :param ps: the population size
227 """
228 logger(f"Beginning experiment with dest={dest!r}, instances={instances!r}"
229 f", n_inst={n_inst}, n_runs={n_runs}, and max_fes={max_fes}.")
230 use_dest: Final[Path] = Path(dest)
231 use_dest.ensure_dir_exists()
232 logger(f"Destination folder is {use_dest!r}.")
234 use_insts: Final[Path] = Path(instances)
235 use_insts.ensure_dir_exists()
236 logger(f"Instances folder is {use_insts!r}.")
238 check_int_range(n_inst, "n_inst", 1, 128)
239 check_int_range(max_fes, "max_fes", 10, 10 ** 10)
240 check_int_range(ps, "ps", 4, 16384)
242 logger(f"Loading {n_inst} instances from {use_insts!r}.")
243 insts: Final[tuple[Instance, ...]] = get_instances(n_inst, instances)
244 if tuple.__len__(insts) != n_inst:
245 raise ValueError("Could not load required instances.")
246 logger(f"Loaded {n_inst} instances from {use_insts!r}.")
248 n_prod: int | None = None
249 for inst in insts:
250 if n_prod is None:
251 n_prod = inst.n_products
252 elif n_prod != inst.n_products:
253 raise ValueError("Inconsistent number of products!")
254 if n_prod is None:
255 raise ValueError("No instances?")
257 search_space: Final[IntSpace] = IntSpace(n_prod, 0, 63)
258 op0: Final[Op0Random] = Op0Random(search_space)
259 op1: Final[Op1MNormal] = Op1MNormal(search_space, sd=2.5)
260 op2: Final = Op2Uniform()
261 algo: Final[NSGA2] = NSGA2(op0, op1, op2, ps, 1 / min(16, ps))
262 solution_space: Final[MultiStatisticsSpace] = MultiStatisticsSpace(insts)
263 encoding: Final[ROPMultiSimulation] = ROPMultiSimulation(solution_space)
264 f1: Final[WorstAndMeanFillRate] = WorstAndMeanFillRate()
265 f2: Final[MaxStockLevel] = MaxStockLevel()
266 ws: Final[WeightedSum] = WeightedSum((f1, f2), (
267 (1 / (f1.upper_bound() - f1.lower_bound())), 1 / (2 * n_prod)))
269 def __setup(_) -> MOExecution:
270 """
271 Set up the experiment.
273 :return: the execution
274 """
275 return (MOExecution()
276 .set_search_space(search_space)
277 .set_algorithm(algo)
278 .set_solution_space(solution_space)
279 .set_objective(ws)
280 .set_encoding(encoding)
281 .set_max_fes(max_fes)
282 .set_log_improvements(True))
284 run_experiment(base_dir=use_dest, instances=(lambda: "all", ),
285 setups=(__setup, ), n_runs=n_runs,
286 pre_warmup_fes=2, perform_warmup=False,
287 perform_pre_warmup=True)
290# Run the experiment from the command line
291if __name__ == "__main__":
292 parser: Final[argparse.ArgumentParser] = moptipyapps_argparser(
293 __file__, "ROP-based MFC Optimization",
294 "Run a small experiment with ROP-based MFC optimization.")
295 parser.add_argument(
296 "dest", help="the directory to store the experimental results under",
297 type=Path, nargs="?", default="./results/")
298 parser.add_argument(
299 "insts", help="the directory with the instances",
300 type=Path, nargs="?", default="./instances/")
301 parser.add_argument(
302 "n_inst", help="the number of instances",
303 type=int, nargs="?", default=11)
304 parser.add_argument(
305 "n_runs", help="the number of runs",
306 type=int, nargs="?", default=31)
307 parser.add_argument(
308 "max_fes", help="the number of FEs per run",
309 type=int, nargs="?", default=8192)
310 parser.add_argument(
311 "ps", help="the population size of NSGA-II",
312 type=int, nargs="?", default=64)
313 args: Final[argparse.Namespace] = parser.parse_args()
314 run(args.dest, args.insts, args.n_inst, args.n_runs, args.max_fes,
315 args.ps)