mlafactory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mlafactory/FactoryRunner.py +411 -0
- mlafactory/FactorySimulator.py +349 -0
- mlafactory/Inventory.py +96 -0
- mlafactory/Machine.py +149 -0
- mlafactory/MachineDispatcher.py +182 -0
- mlafactory/__init__.py +0 -0
- mlafactory/aux_arguments.py +229 -0
- mlafactory/aux_files.py +42 -0
- mlafactory/aux_intervals.py +75 -0
- mlafactory/aux_reports.py +149 -0
- mlafactory/minimal_example/__init__.py +0 -0
- mlafactory/minimal_example/bom.json +90 -0
- mlafactory/minimal_example/initial_inventory.json +31 -0
- mlafactory/minimal_example/machines.json +16 -0
- mlafactory/minimal_example/orders_demand.json +12 -0
- mlafactory/minimal_example/reorder_delays.json +23 -0
- mlafactory/minimal_example/stock_limits.json +106 -0
- mlafactory/run_factory.py +48 -0
- mlafactory-0.1.0.dist-info/METADATA +337 -0
- mlafactory-0.1.0.dist-info/RECORD +24 -0
- mlafactory-0.1.0.dist-info/WHEEL +5 -0
- mlafactory-0.1.0.dist-info/entry_points.txt +2 -0
- mlafactory-0.1.0.dist-info/licenses/LICENSE +11 -0
- mlafactory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mlafactory Simulation Framework
|
|
3
|
+
|
|
4
|
+
This project implements a discrete-event simulation framework for modeling
|
|
5
|
+
multi-level assembly (MLA) manufacturing systems using the SimPy library.
|
|
6
|
+
|
|
7
|
+
Modules in this project simulate:
|
|
8
|
+
- Order processing and dynamic production scheduling
|
|
9
|
+
- Inventory management with configurable stock thresholds
|
|
10
|
+
- Machine operations and resource planning
|
|
11
|
+
- Multi-level component dependencies using bill of materials (BOM)
|
|
12
|
+
- Automated report generation and performance analysis
|
|
13
|
+
|
|
14
|
+
Developed by: Oscar J. Castro-Lopez
|
|
15
|
+
Affiliation: University of Luxembourg
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import random
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Union
|
|
24
|
+
|
|
25
|
+
import pandas as pd
|
|
26
|
+
import simpy
|
|
27
|
+
import yaml
|
|
28
|
+
from mlafactory.aux_files import InputValidationError
|
|
29
|
+
from mlafactory.aux_intervals import generate_wait_intervals
|
|
30
|
+
from mlafactory.aux_reports import collect_results, save_reports_to_folder
|
|
31
|
+
from mlafactory.FactorySimulator import FactorySimulator
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def setup_logging(debug_mode, factory_name):
|
|
35
|
+
"""Configures logging based on debug flag."""
|
|
36
|
+
if debug_mode:
|
|
37
|
+
logging.basicConfig(
|
|
38
|
+
level=logging.DEBUG,
|
|
39
|
+
format="%(levelname)s: %(message)s",
|
|
40
|
+
filename=f"{factory_name}.log",
|
|
41
|
+
filemode="w", # 'w' overwrites
|
|
42
|
+
encoding="utf-8",
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
logging.basicConfig(
|
|
46
|
+
level=logging.INFO,
|
|
47
|
+
format="%(levelname)s: %(message)s",
|
|
48
|
+
handlers=[logging.StreamHandler()], # Log to console only
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FactoryRunner:
|
|
53
|
+
"""
|
|
54
|
+
Orchestrator class for running a multi-level assembly (MLA)
|
|
55
|
+
factory simulation.
|
|
56
|
+
|
|
57
|
+
This class configures the simulation environment, validates inputs,
|
|
58
|
+
triggers, production scheduling, and collects/report results.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
factory_name: str
|
|
62
|
+
Name of the factory.
|
|
63
|
+
product_demand: dict
|
|
64
|
+
Demand per order ID, mapping to products and quantities.
|
|
65
|
+
bom: dict
|
|
66
|
+
Bill of materials structure.
|
|
67
|
+
initial_inventory: dict
|
|
68
|
+
Initial inventory levels for all components.
|
|
69
|
+
machines: list
|
|
70
|
+
List of machines with their capabilities and specs.
|
|
71
|
+
stock_limits: dict
|
|
72
|
+
Min/max stock level limits for each component.
|
|
73
|
+
reorder_delays: dict
|
|
74
|
+
Metadata for raw materials.
|
|
75
|
+
inventory_strategy: str
|
|
76
|
+
Strategy to initialize inventory of raw material. Default 'max'.
|
|
77
|
+
inventory_seed: int
|
|
78
|
+
Random seed in case the strategy of initialize inventory is random.
|
|
79
|
+
Default 42.
|
|
80
|
+
months: int
|
|
81
|
+
Simulation duration in months (default: 12).
|
|
82
|
+
hours_per_month: int
|
|
83
|
+
Work hours per month (default: 160).
|
|
84
|
+
order_mode: str
|
|
85
|
+
How orders are spaced ("fixed", "random", etc.).
|
|
86
|
+
order_mode_params: dict
|
|
87
|
+
Parameters controlling order spacing.
|
|
88
|
+
show_output: bool
|
|
89
|
+
Whether to print results to console.
|
|
90
|
+
show_debug: bool
|
|
91
|
+
Enable debug logging.
|
|
92
|
+
export_reports: bool
|
|
93
|
+
Save reports to disk after simulation.
|
|
94
|
+
output_dir: str
|
|
95
|
+
Output folder for saving reports.
|
|
96
|
+
waiting_intervals: List[int], Optional
|
|
97
|
+
Precomputed wait intervals (optional).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
factory_name: str,
|
|
103
|
+
product_demand: Dict,
|
|
104
|
+
bom: Dict,
|
|
105
|
+
initial_inventory: Dict,
|
|
106
|
+
machines: List,
|
|
107
|
+
stock_limits: Dict,
|
|
108
|
+
reorder_delays: Dict,
|
|
109
|
+
inventory_strategy: str = "max",
|
|
110
|
+
inventory_seed: int = 42,
|
|
111
|
+
months: int = 12,
|
|
112
|
+
hours_per_month: int = 160,
|
|
113
|
+
order_mode: str = "fixed",
|
|
114
|
+
order_mode_params: Union[Dict, None] = None,
|
|
115
|
+
show_output: bool = False,
|
|
116
|
+
show_debug: bool = False,
|
|
117
|
+
export_reports: bool = False,
|
|
118
|
+
output_dir: str = "output/",
|
|
119
|
+
waiting_intervals: Union[List, None] = None,
|
|
120
|
+
):
|
|
121
|
+
# Simulation config
|
|
122
|
+
self.factory_name = factory_name
|
|
123
|
+
self.product_demand = product_demand
|
|
124
|
+
self.bom = bom
|
|
125
|
+
self.initial_inventory = initial_inventory
|
|
126
|
+
self.machines = machines
|
|
127
|
+
self.stock_limits = stock_limits
|
|
128
|
+
self.reorder_delays = reorder_delays
|
|
129
|
+
self.inventory_strategy = inventory_strategy
|
|
130
|
+
self.inventory_seed = inventory_seed
|
|
131
|
+
self.order_mode = order_mode
|
|
132
|
+
self.order_mode_params = order_mode_params or {"interval": 1}
|
|
133
|
+
self.simulation_time = months * hours_per_month
|
|
134
|
+
self.months = months
|
|
135
|
+
self.hours_per_month = hours_per_month
|
|
136
|
+
|
|
137
|
+
# Logging and output options
|
|
138
|
+
self.show_output = show_output
|
|
139
|
+
self.show_debug = show_debug
|
|
140
|
+
self.export_reports = export_reports
|
|
141
|
+
self.output_dir = output_dir
|
|
142
|
+
|
|
143
|
+
self.env = simpy.Environment()
|
|
144
|
+
self.waiting_intervals = waiting_intervals
|
|
145
|
+
|
|
146
|
+
self._setup_logging()
|
|
147
|
+
|
|
148
|
+
def _setup_logging(self):
|
|
149
|
+
"""Configure logging for this simulation run."""
|
|
150
|
+
setup_logging(self.show_debug, self.factory_name)
|
|
151
|
+
|
|
152
|
+
def _generate_waiting_intervals(self):
|
|
153
|
+
"""Generate order wait intervals based on configured
|
|
154
|
+
mode and parameters."""
|
|
155
|
+
return generate_wait_intervals(
|
|
156
|
+
n_orders=len(self.product_demand),
|
|
157
|
+
mode=self.order_mode,
|
|
158
|
+
params=self.order_mode_params,
|
|
159
|
+
simulation_time=self.simulation_time,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _validate_raw_materials(self):
|
|
163
|
+
for product, details in self.bom.items():
|
|
164
|
+
for comp, comp_details in details.get("bom", {}).items():
|
|
165
|
+
if (
|
|
166
|
+
comp_details.get("type") == "raw"
|
|
167
|
+
and comp not in self.reorder_delays
|
|
168
|
+
):
|
|
169
|
+
msg = (
|
|
170
|
+
f"Raw component '{comp}' used in '{product}' "
|
|
171
|
+
"is missing in component_info."
|
|
172
|
+
)
|
|
173
|
+
logging.error(msg)
|
|
174
|
+
raise InputValidationError(msg)
|
|
175
|
+
|
|
176
|
+
def _validate_demand_products(self):
|
|
177
|
+
bom_products = set(self.bom)
|
|
178
|
+
for order_id, order_items in self.product_demand.items():
|
|
179
|
+
for product in order_items:
|
|
180
|
+
if product not in bom_products:
|
|
181
|
+
msg = (
|
|
182
|
+
f"Product '{product}' in order '{order_id}' "
|
|
183
|
+
"is not defined in BOM."
|
|
184
|
+
)
|
|
185
|
+
logging.error(msg)
|
|
186
|
+
raise InputValidationError(msg)
|
|
187
|
+
|
|
188
|
+
def _validate_stock_limits(self):
|
|
189
|
+
bom_products = set(self.bom)
|
|
190
|
+
missing_limits = [
|
|
191
|
+
item
|
|
192
|
+
for item in bom_products | set(self.reorder_delays)
|
|
193
|
+
if item not in self.stock_limits
|
|
194
|
+
]
|
|
195
|
+
if missing_limits:
|
|
196
|
+
for item in missing_limits:
|
|
197
|
+
logging.error("Missing stock_limits entry for '%s'.", item)
|
|
198
|
+
raise InputValidationError(
|
|
199
|
+
"Stock limits missing for one or more components."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _initialize_inventory(self):
|
|
203
|
+
rng = random.Random(self.inventory_seed)
|
|
204
|
+
for product, details in self.bom.items():
|
|
205
|
+
self._set_initial_inventory(product, 0)
|
|
206
|
+
for comp, comp_details in details.get("bom", {}).items():
|
|
207
|
+
initial_value = self._get_initial_value(comp, comp_details, rng)
|
|
208
|
+
self._set_initial_inventory(comp, initial_value)
|
|
209
|
+
|
|
210
|
+
def _get_initial_value(self, comp, comp_details, rng):
|
|
211
|
+
if comp_details.get("type") != "raw":
|
|
212
|
+
return 0
|
|
213
|
+
strategy = self.inventory_strategy
|
|
214
|
+
limits = self.stock_limits[comp]
|
|
215
|
+
if strategy == "max":
|
|
216
|
+
return limits["max"]
|
|
217
|
+
if strategy == "min":
|
|
218
|
+
return limits["min"]
|
|
219
|
+
if strategy == "random":
|
|
220
|
+
return rng.randint(limits["min"], limits["max"])
|
|
221
|
+
if strategy == "zero":
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
def _set_initial_inventory(self, item, value):
|
|
225
|
+
self.initial_inventory.setdefault(item, value)
|
|
226
|
+
logging.debug(
|
|
227
|
+
"Inventory initialized: %s = %s", item, self.initial_inventory[item]
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _check_min_stock_constraints(self):
|
|
231
|
+
for product, details in self.bom.items():
|
|
232
|
+
components = details.get("bom", {})
|
|
233
|
+
for comp_key, comp_val in components.items():
|
|
234
|
+
required_qty = comp_val["qty"]
|
|
235
|
+
min_stock = self.stock_limits[comp_key]["min"]
|
|
236
|
+
if required_qty > min_stock:
|
|
237
|
+
logging.warning(
|
|
238
|
+
"Component '%s': min stock (%s) is less than required "
|
|
239
|
+
"in BOM for '%s' (%s). Simulation may get stuck if "
|
|
240
|
+
"stock is depleted.",
|
|
241
|
+
comp_key,
|
|
242
|
+
min_stock,
|
|
243
|
+
product,
|
|
244
|
+
required_qty,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def _validate_bom_machines(self):
|
|
248
|
+
"""
|
|
249
|
+
Validate that the machines are able to build
|
|
250
|
+
the products specified in the BOM,
|
|
251
|
+
Raises:
|
|
252
|
+
InputValidationError: If a type of machine is missing.
|
|
253
|
+
"""
|
|
254
|
+
machine_types = set()
|
|
255
|
+
for machine in self.machines:
|
|
256
|
+
machine_types.add(machine["type"])
|
|
257
|
+
|
|
258
|
+
bom_set = set()
|
|
259
|
+
for _, product_val in self.bom.items():
|
|
260
|
+
bom_set.add(product_val["type"])
|
|
261
|
+
|
|
262
|
+
difference = bom_set - machine_types
|
|
263
|
+
if len(difference) > 0:
|
|
264
|
+
msg = f"There are no machines for the products: {difference}."
|
|
265
|
+
logging.error(msg)
|
|
266
|
+
raise InputValidationError(msg)
|
|
267
|
+
|
|
268
|
+
def _start_production(
|
|
269
|
+
self,
|
|
270
|
+
factory: FactorySimulator,
|
|
271
|
+
):
|
|
272
|
+
"""
|
|
273
|
+
Start the production process by adding orders at specified intervals.
|
|
274
|
+
"""
|
|
275
|
+
for wait, (key, val) in zip(self.wait_intervals, self.product_demand.items()):
|
|
276
|
+
yield self.env.timeout(wait)
|
|
277
|
+
products = val
|
|
278
|
+
for product_type, quantity in products.items():
|
|
279
|
+
factory.add_order(key, product_type, quantity)
|
|
280
|
+
|
|
281
|
+
def prepare_and_validate(self):
|
|
282
|
+
"""
|
|
283
|
+
Validate all major inputs before running the simulation.
|
|
284
|
+
Ensures consistency across BOM, inventory, demand, and
|
|
285
|
+
component metadata.
|
|
286
|
+
Raises:
|
|
287
|
+
InputValidationError: If any input validation check fails.
|
|
288
|
+
"""
|
|
289
|
+
self._validate_bom_machines()
|
|
290
|
+
self._validate_raw_materials()
|
|
291
|
+
self._validate_demand_products()
|
|
292
|
+
self._validate_stock_limits()
|
|
293
|
+
self._initialize_inventory()
|
|
294
|
+
self._check_min_stock_constraints()
|
|
295
|
+
logging.debug("All input validations passed.")
|
|
296
|
+
|
|
297
|
+
def run(self) -> Dict:
|
|
298
|
+
"""
|
|
299
|
+
Execute the simulation and return collected results.
|
|
300
|
+
Returns:
|
|
301
|
+
dict: Summary statistics including production, inventory, and
|
|
302
|
+
machine usage.
|
|
303
|
+
"""
|
|
304
|
+
self.env = simpy.Environment() # reset for re-runs
|
|
305
|
+
self.prepare_and_validate()
|
|
306
|
+
|
|
307
|
+
self.wait_intervals = (
|
|
308
|
+
self.waiting_intervals or self._generate_waiting_intervals()
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
factory = FactorySimulator(
|
|
312
|
+
self.env,
|
|
313
|
+
self.factory_name,
|
|
314
|
+
self.machines,
|
|
315
|
+
self.initial_inventory,
|
|
316
|
+
self.reorder_delays,
|
|
317
|
+
self.bom,
|
|
318
|
+
self.stock_limits,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Start process
|
|
322
|
+
self.env.process(self._start_production(factory))
|
|
323
|
+
|
|
324
|
+
# Run environment
|
|
325
|
+
start_time = time.time()
|
|
326
|
+
self.env.run(until=self.simulation_time)
|
|
327
|
+
elapsed = time.time() - start_time
|
|
328
|
+
logging.debug("Elapsed real-world time: %s seconds", f"{elapsed:.2f}")
|
|
329
|
+
|
|
330
|
+
# Add the last time to the machines
|
|
331
|
+
for _, machine_dict in factory.machines.items():
|
|
332
|
+
for machine in machine_dict["machines"]:
|
|
333
|
+
last_task, _, _ = machine.current_task
|
|
334
|
+
machine.task_durations[last_task] += (
|
|
335
|
+
self.simulation_time - machine.last_start
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Collect results
|
|
339
|
+
(production_summary, machines_summary, inventory_summary) = collect_results(
|
|
340
|
+
factory, self.product_demand
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if self.show_output or self.export_reports:
|
|
344
|
+
production_df = pd.DataFrame(production_summary)
|
|
345
|
+
machines_df = pd.DataFrame(machines_summary)
|
|
346
|
+
inventory_df = pd.DataFrame(inventory_summary).T.reset_index()
|
|
347
|
+
inventory_df = inventory_df.rename(columns={"index": "item"})
|
|
348
|
+
self._show_output(elapsed, production_df, machines_df, inventory_df)
|
|
349
|
+
self._export_reports(factory, production_df, machines_df, inventory_df)
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
"simulation_time": self.simulation_time,
|
|
353
|
+
"real_simulation_time": elapsed,
|
|
354
|
+
"production": production_summary,
|
|
355
|
+
"machines": machines_summary,
|
|
356
|
+
"inventory": inventory_summary,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
def _show_output(self, elapsed, production_df, machines_df, inventory_df):
|
|
360
|
+
"""Prints simulation results to console."""
|
|
361
|
+
if self.show_output:
|
|
362
|
+
print(f"Elapsed real time: {elapsed}")
|
|
363
|
+
print(f"Total simulated time: {self.simulation_time} hours")
|
|
364
|
+
print("=" * 40, "Production Results", "=" * 40)
|
|
365
|
+
print(production_df)
|
|
366
|
+
print("=" * 40, "Machines Summary", "=" * 40)
|
|
367
|
+
print(machines_df)
|
|
368
|
+
print("=" * 40, "Inventory Summary", "=" * 40)
|
|
369
|
+
print(inventory_df)
|
|
370
|
+
|
|
371
|
+
def _export_reports(self, factory, production_df, machines_df, inventory_df):
|
|
372
|
+
"""Save simulation reports to disk."""
|
|
373
|
+
if self.export_reports:
|
|
374
|
+
save_reports_to_folder(
|
|
375
|
+
factory,
|
|
376
|
+
self.factory_name,
|
|
377
|
+
self.output_dir,
|
|
378
|
+
production_df,
|
|
379
|
+
machines_df,
|
|
380
|
+
inventory_df,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
@classmethod
|
|
384
|
+
def from_config(cls, path: str) -> "FactoryRunner":
|
|
385
|
+
"""
|
|
386
|
+
Load configuration from a JSON or YAML file and create a FactoryRunner
|
|
387
|
+
instance.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
path: Path to a JSON or YAML configuration file.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
FactoryRunner instance.
|
|
394
|
+
"""
|
|
395
|
+
config_path = Path(path)
|
|
396
|
+
if not config_path.exists():
|
|
397
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
398
|
+
|
|
399
|
+
# Detect file type
|
|
400
|
+
if config_path.suffix in [".yaml", ".yml"]:
|
|
401
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
402
|
+
config = yaml.safe_load(f)
|
|
403
|
+
elif config_path.suffix == ".json":
|
|
404
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
405
|
+
config = json.load(f)
|
|
406
|
+
else:
|
|
407
|
+
raise ValueError(f"Unsupported config format: {config_path.suffix}")
|
|
408
|
+
|
|
409
|
+
# Optional: validate required keys or normalize config here
|
|
410
|
+
|
|
411
|
+
return cls(**config)
|