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.
@@ -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)