haileyhalimj@gmail.com commited on
Commit
fa2c20f
Β·
1 Parent(s): 8c62ccd

Refactor optimization configuration and constants integration

Browse files

- Updated `optimization_config.py` to utilize constants from `constants.py` for better maintainability.
- Added new constants for line counts, maximum parallel workers, and default rates.
- Enhanced session state initialization in `config_page.py` to use values from `DefaultConfig`.
- Improved architecture diagram to reflect changes in optimization flow.

ARCHITECTURE_DIAGRAM.md CHANGED
@@ -486,7 +486,7 @@ sequenceDiagram
486
  Config->>OptConfig: Get all parameters
487
  OptConfig-->>Config: Return config
488
 
489
- Config->>Optimizer: run_optimization_for_week()
490
 
491
  Optimizer->>OptConfig: Get products
492
  Optimizer->>OptConfig: Get demand
 
486
  Config->>OptConfig: Get all parameters
487
  OptConfig-->>Config: Return config
488
 
489
+ Config->>Optimizer: Optimizer().run_optimization()
490
 
491
  Optimizer->>OptConfig: Get products
492
  Optimizer->>OptConfig: Get demand
src/config/constants.py CHANGED
@@ -2,6 +2,7 @@
2
  Constants module for Supply Roster Optimization Tool
3
  Replaces hard-coded magic numbers with meaningful named constants
4
  """
 
5
 
6
  class ShiftType:
7
  """
@@ -138,6 +139,14 @@ class DefaultConfig:
138
  # Default minimum UNICEF fixed-term employees per day
139
  FIXED_MIN_UNICEF_PER_DAY = 2
140
 
 
 
 
 
 
 
 
 
141
  # Default cost rates (example values)
142
  DEFAULT_COST_RATES = {
143
  "UNICEF Fixed term": {
@@ -150,4 +159,44 @@ class DefaultConfig:
150
  ShiftType.EVENING: 27.94,
151
  ShiftType.OVERTIME: 41.91
152
  }
153
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  Constants module for Supply Roster Optimization Tool
3
  Replaces hard-coded magic numbers with meaningful named constants
4
  """
5
+ from src.preprocess import extract
6
 
7
  class ShiftType:
8
  """
 
139
  # Default minimum UNICEF fixed-term employees per day
140
  FIXED_MIN_UNICEF_PER_DAY = 2
141
 
142
+ # Default line counts
143
+ LINE_COUNT_LONG_LINE = 3
144
+ LINE_COUNT_MINI_LOAD = 2
145
+
146
+ # Default max parallel workers per line (for UI)
147
+ MAX_PARALLEL_WORKERS_LONG_LINE = 7
148
+ MAX_PARALLEL_WORKERS_MINI_LOAD = 5
149
+
150
  # Default cost rates (example values)
151
  DEFAULT_COST_RATES = {
152
  "UNICEF Fixed term": {
 
159
  ShiftType.EVENING: 27.94,
160
  ShiftType.OVERTIME: 41.91
161
  }
162
+ }
163
+ #get employee type list from data files
164
+ EMPLOYEE_TYPE_LIST =extract.read_employee_data()["employment_type"].unique().tolist()
165
+ SHIFT_LIST = extract.get_shift_info()["id"].unique().tolist()
166
+ EVENING_SHIFT_MODE = "normal"
167
+ EVENING_SHIFT_DEMAND_THRESHOLD = 0.9
168
+
169
+ # Default schedule type
170
+ SCHEDULE_TYPE = "weekly"
171
+
172
+ # Default fixed staff mode
173
+ FIXED_STAFF_MODE = "priority"
174
+
175
+ # Default hourly rates for UI (simplified)
176
+ UNICEF_RATE_SHIFT_1 = 12.5
177
+ UNICEF_RATE_SHIFT_2 = 15.0
178
+ UNICEF_RATE_SHIFT_3 = 18.75
179
+ HUMANIZER_RATE_SHIFT_1 = 10.0
180
+ HUMANIZER_RATE_SHIFT_2 = 12.0
181
+ HUMANIZER_RATE_SHIFT_3 = 15.0
182
+ LINE_LIST = extract.read_packaging_line_data()["id"].unique().tolist()
183
+ LINE_CNT_PER_TYPE = extract.read_packaging_line_data().set_index("id")["line_count"].to_dict()
184
+
185
+ # Dynamic method to get max employee per type on day
186
+ @staticmethod
187
+ def get_max_employee_per_type_on_day(date_span):
188
+ """Get max employee per type configuration for given date span"""
189
+ return {
190
+ "UNICEF Fixed term": {
191
+ t: 8 for t in date_span
192
+ },
193
+ "Humanizer": {
194
+ t: 10 for t in date_span
195
+ }
196
+ }
197
+ MAX_UNICEF_PER_DAY = 8
198
+ MAX_HUMANIZER_PER_DAY = 10
199
+ MAX_HOUR_PER_PERSON_PER_DAY = 14
200
+ KIT_LEVELS, KIT_DEPENDENCIES, PRODUCTION_PRIORITY_ORDER = extract.get_production_order_data()
201
+
202
+
src/config/optimization_config.py CHANGED
@@ -91,6 +91,7 @@ EVENING_SHIFT_MODE = "normal" # Default: only regular + overtime
91
  # If demand cannot be met with regular + overtime, suggest evening shift activation
92
  EVENING_SHIFT_DEMAND_THRESHOLD = 0.9 # Activate if regular+overtime capacity < 90% of demand
93
 
 
94
  def get_active_shift_list():
95
  """
96
  Get the list of active shifts based on EVENING_SHIFT_MODE setting.
@@ -122,7 +123,7 @@ def get_active_shift_list():
122
  # DO NOT load at import time - always call get_active_shift_list() dynamically
123
  # SHIFT_LIST = get_active_shift_list() # REMOVED - was causing stale data!
124
 
125
-
126
  def get_line_list():
127
  """Get line list - try from streamlit session state first, then from data files"""
128
  try:
@@ -143,7 +144,7 @@ def get_line_list():
143
  # DO NOT load at import time - always call get_line_list() dynamically
144
  # LINE_LIST = get_line_list() # REMOVED - was causing stale data!
145
 
146
-
147
  def get_kit_line_match():
148
  kit_line_match = extract.read_kit_line_match_data()
149
  kit_line_match_dict = kit_line_match.set_index("kit_name")["line_type"].to_dict()
@@ -200,6 +201,7 @@ def get_line_cnt_per_type():
200
  # DO NOT load at import time - always call get_line_cnt_per_type() dynamically
201
  # LINE_CNT_PER_TYPE = get_line_cnt_per_type() # REMOVED - was causing stale data!
202
 
 
203
  def get_demand_dictionary(force_reload=False):
204
  """
205
  Get filtered demand dictionary.
@@ -224,6 +226,7 @@ def get_demand_dictionary(force_reload=False):
224
  # DO NOT load at import time - always call get_demand_dictionary() dynamically
225
  # DEMAND_DICTIONARY = get_demand_dictionary() # REMOVED - was causing stale data!
226
 
 
227
  def get_cost_list_per_emp_shift():
228
  try:
229
  # Try to get from streamlit session state (from config page)
@@ -260,7 +263,7 @@ def line_code_to_name():
260
 
261
 
262
 
263
-
264
  def get_team_requirements(product_list=None):
265
  """
266
  Extract team requirements from Kits Calculation CSV.
@@ -310,58 +313,57 @@ def get_max_employee_per_type_on_day():
310
  print(f"Could not get max employee counts from streamlit session: {e}")
311
 
312
  print(f"Loading default max employee values")
 
 
 
 
 
 
313
  max_employee_per_type_on_day = {
314
  "UNICEF Fixed term": {
315
- t: 8 for t in DATE_SPAN
316
  },
317
  "Humanizer": {
318
- t: 10 for t in DATE_SPAN
319
  }
320
  }
321
  return max_employee_per_type_on_day
322
 
323
 
324
-
325
  MAX_HOUR_PER_PERSON_PER_DAY = 14 # legal standard
326
  def get_max_hour_per_shift_per_person():
327
- """Get max hours per shift per person - checks Streamlit session state first"""
328
  try:
329
  import streamlit as st
330
- if hasattr(st, 'session_state') and 'max_hour_per_shift_per_person' in st.session_state:
331
- return st.session_state.max_hour_per_shift_per_person
 
 
 
 
 
 
332
  except Exception as e:
333
  print(f"Could not get max hours per shift from session: {e}")
334
 
335
- # Fallback to default only if not configured by user
336
  return DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON
337
 
338
 
339
 
340
  # Keep these complex getters that access DefaultConfig or have complex logic:
341
  def get_evening_shift_demand_threshold():
342
- """Get evening shift demand threshold - checks Streamlit session state first"""
343
  try:
344
  import streamlit as st
345
- if hasattr(st, 'session_state') and 'evening_shift_demand_threshold' in st.session_state:
346
- return st.session_state.evening_shift_demand_threshold
347
  except Exception as e:
348
  print(f"Could not get evening shift threshold from session: {e}")
349
 
350
- # Fallback to default only if not configured by user
351
- return getattr(DefaultConfig, 'EVENING_SHIFT_DEMAND_THRESHOLD', 10000)
352
-
353
- def get_fixed_min_unicef_per_day():
354
- """Get fixed minimum UNICEF staff per day - checks Streamlit session state first"""
355
- try:
356
- import streamlit as st
357
- if hasattr(st, 'session_state') and 'fixed_min_unicef_per_day' in st.session_state:
358
- return st.session_state.fixed_min_unicef_per_day
359
- except Exception as e:
360
- print(f"Could not get fixed min UNICEF from session: {e}")
361
-
362
- # Fallback to default only if not configured by user
363
- return getattr(DefaultConfig, 'FIXED_MIN_UNICEF_PER_DAY', {1: 1, 2: 1, 3: 1, 4: 1, 5: 1})
364
-
365
 
366
 
367
  # ---- Kit Hierarchy for Production Ordering ----
@@ -383,22 +385,25 @@ def get_kit_dependencies():
383
  _, dependencies, _ = get_kit_hierarchy_data()
384
  return dependencies
385
 
386
- def get_production_priority_order():
387
- """Get production priority order lazily - returns [kit_ids] sorted by production priority"""
388
- _, _, priority_order = get_kit_hierarchy_data()
389
- return priority_order
390
-
391
  def get_max_parallel_workers():
392
- """Get max parallel workers - checks Streamlit session state first"""
393
  try:
394
  import streamlit as st
395
- if hasattr(st, 'session_state') and 'max_parallel_workers' in st.session_state:
396
- return st.session_state.max_parallel_workers
 
 
 
 
 
397
  except Exception as e:
398
  print(f"Could not get max parallel workers from session: {e}")
399
 
400
- # Fallback to default only if not configured by user
401
- return DefaultConfig.MAX_PARALLEL_WORKERS
 
 
 
402
 
403
 
404
 
 
91
  # If demand cannot be met with regular + overtime, suggest evening shift activation
92
  EVENING_SHIFT_DEMAND_THRESHOLD = 0.9 # Activate if regular+overtime capacity < 90% of demand
93
 
94
+ #Where?
95
  def get_active_shift_list():
96
  """
97
  Get the list of active shifts based on EVENING_SHIFT_MODE setting.
 
123
  # DO NOT load at import time - always call get_active_shift_list() dynamically
124
  # SHIFT_LIST = get_active_shift_list() # REMOVED - was causing stale data!
125
 
126
+ #where?
127
  def get_line_list():
128
  """Get line list - try from streamlit session state first, then from data files"""
129
  try:
 
144
  # DO NOT load at import time - always call get_line_list() dynamically
145
  # LINE_LIST = get_line_list() # REMOVED - was causing stale data!
146
 
147
+ #where?
148
  def get_kit_line_match():
149
  kit_line_match = extract.read_kit_line_match_data()
150
  kit_line_match_dict = kit_line_match.set_index("kit_name")["line_type"].to_dict()
 
201
  # DO NOT load at import time - always call get_line_cnt_per_type() dynamically
202
  # LINE_CNT_PER_TYPE = get_line_cnt_per_type() # REMOVED - was causing stale data!
203
 
204
+ #where?
205
  def get_demand_dictionary(force_reload=False):
206
  """
207
  Get filtered demand dictionary.
 
226
  # DO NOT load at import time - always call get_demand_dictionary() dynamically
227
  # DEMAND_DICTIONARY = get_demand_dictionary() # REMOVED - was causing stale data!
228
 
229
+ #delete as already using default cost rates
230
  def get_cost_list_per_emp_shift():
231
  try:
232
  # Try to get from streamlit session state (from config page)
 
263
 
264
 
265
 
266
+ #where to put?
267
  def get_team_requirements(product_list=None):
268
  """
269
  Extract team requirements from Kits Calculation CSV.
 
313
  print(f"Could not get max employee counts from streamlit session: {e}")
314
 
315
  print(f"Loading default max employee values")
316
+ # Get date span dynamically if not available
317
+ if DATE_SPAN is None:
318
+ date_span, _, _ = get_date_span()
319
+ else:
320
+ date_span = DATE_SPAN
321
+
322
  max_employee_per_type_on_day = {
323
  "UNICEF Fixed term": {
324
+ t: 8 for t in date_span
325
  },
326
  "Humanizer": {
327
+ t: 10 for t in date_span
328
  }
329
  }
330
  return max_employee_per_type_on_day
331
 
332
 
333
+ # Keep the constant for backward compatibility, but use function instead
334
  MAX_HOUR_PER_PERSON_PER_DAY = 14 # legal standard
335
  def get_max_hour_per_shift_per_person():
336
+ """Get max hours per shift per person from session state or default"""
337
  try:
338
  import streamlit as st
339
+ if hasattr(st, 'session_state'):
340
+ # Build from individual session state values
341
+ max_hours = {
342
+ ShiftType.REGULAR: st.session_state.get('max_hours_shift_1', DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.REGULAR]),
343
+ ShiftType.EVENING: st.session_state.get('max_hours_shift_2', DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.EVENING]),
344
+ ShiftType.OVERTIME: st.session_state.get('max_hours_shift_3', DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.OVERTIME])
345
+ }
346
+ return max_hours
347
  except Exception as e:
348
  print(f"Could not get max hours per shift from session: {e}")
349
 
350
+ # Fallback to default
351
  return DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON
352
 
353
 
354
 
355
  # Keep these complex getters that access DefaultConfig or have complex logic:
356
  def get_evening_shift_demand_threshold():
357
+ """Get evening shift demand threshold from session state or default"""
358
  try:
359
  import streamlit as st
360
+ if hasattr(st, 'session_state'):
361
+ return st.session_state.get('evening_shift_threshold', DefaultConfig.EVENING_SHIFT_DEMAND_THRESHOLD)
362
  except Exception as e:
363
  print(f"Could not get evening shift threshold from session: {e}")
364
 
365
+ # Fallback to default
366
+ return DefaultConfig.EVENING_SHIFT_DEMAND_THRESHOLD
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
 
369
  # ---- Kit Hierarchy for Production Ordering ----
 
385
  _, dependencies, _ = get_kit_hierarchy_data()
386
  return dependencies
387
 
 
 
 
 
 
388
  def get_max_parallel_workers():
389
+ """Get max parallel workers from session state or default"""
390
  try:
391
  import streamlit as st
392
+ if hasattr(st, 'session_state'):
393
+ # Build from individual session state values
394
+ max_parallel_workers = {
395
+ LineType.LONG_LINE: st.session_state.get('max_parallel_workers_long_line', DefaultConfig.MAX_PARALLEL_WORKERS_LONG_LINE),
396
+ LineType.MINI_LOAD: st.session_state.get('max_parallel_workers_mini_load', DefaultConfig.MAX_PARALLEL_WORKERS_MINI_LOAD)
397
+ }
398
+ return max_parallel_workers
399
  except Exception as e:
400
  print(f"Could not get max parallel workers from session: {e}")
401
 
402
+ # Fallback to default
403
+ return {
404
+ LineType.LONG_LINE: DefaultConfig.MAX_PARALLEL_WORKERS_LONG_LINE,
405
+ LineType.MINI_LOAD: DefaultConfig.MAX_PARALLEL_WORKERS_MINI_LOAD
406
+ }
407
 
408
 
409
 
src/models/optimizer_real.py CHANGED
@@ -9,718 +9,772 @@
9
 
10
  from ortools.linear_solver import pywraplp
11
  from math import ceil
 
12
  from src.config.constants import ShiftType, LineType, KitLevel
13
 
14
  # ---- config import ----
15
- from src.config.optimization_config import (
16
- get_date_span, # DYNAMIC: Get date span dynamically
17
- get_product_list, # DYNAMIC: list of products (e.g., ['A','B',...])
18
- get_employee_type_list, # DYNAMIC: e.g., ['UNICEF Fixed term','Humanizer']
19
- get_active_shift_list, # DYNAMIC: e.g., [1,2,3]
20
- get_line_list, # DYNAMIC: e.g., [6,7] (line type ids)
21
- get_line_cnt_per_type, # DYNAMIC: {6: count_of_long_lines, 7: count_of_short_lines}
22
- get_demand_dictionary, # DYNAMIC: {product: total_units_over_period}
23
- get_cost_list_per_emp_shift, # DYNAMIC: {emp_type: {shift: cost_per_hour}}
24
- get_max_employee_per_type_on_day, # DYNAMIC: {emp_type: {t: headcount}}
25
- MAX_HOUR_PER_PERSON_PER_DAY, # e.g., 14
26
- get_max_hour_per_shift_per_person, # DYNAMIC: {1: hours, 2: hours, 3: hours}
27
- get_max_parallel_workers, # DYNAMIC: {6: max_workers, 7: max_workers}
28
- get_team_requirements, # DYNAMIC: {emp_type: {product: team_size}} from Kits_Calculation.csv
29
- get_payment_mode_config, # DYNAMIC: {shift: 'bulk'/'partial'} payment mode configuration
30
- get_kit_line_match, # DYNAMIC: Get kit line match lazily
31
- EVENING_SHIFT_MODE,
32
- EVENING_SHIFT_DEMAND_THRESHOLD,
33
- # Hierarchy variables for production ordering
34
- get_kit_levels, # DYNAMIC: {kit_id: level} where 0=prepack, 1=subkit, 2=master
35
- get_kit_dependencies, # DYNAMIC: {kit_id: [dependency_list]}
36
- get_production_priority_order, # DYNAMIC: [kit_ids] sorted by production priority
37
- # Fixed staffing requirements
38
- get_fixed_min_unicef_per_day, # DYNAMIC: Minimum UNICEF employees required per day
39
- )
40
-
41
-
42
-
43
- # 2) kit_line_match - no printing to avoid spam
44
- # KIT_LINE_MATCH_DICT loaded lazily in functions that need it
45
-
46
- # 3) If specific product is not produced on specific date, set it to 0
47
- # ACTIVE will be built dynamically in solve function based on fresh PRODUCT_LIST
48
- # Example: ACTIVE[2]['C'] = 0 # Disable product C on day 2
49
-
50
-
51
- def build_lines():
52
- """List of line instances.
53
- line_tuples elements are (line_type_id, idx) tuples. e.g., (6,1), (6,2), (7,1), ...
54
- """
55
- line_tuples = []
56
- LINE_LIST = get_line_list() # Dynamic call
57
- LINE_CNT_PER_TYPE = get_line_cnt_per_type() # Dynamic call
58
-
59
- for lt in LINE_LIST: # lt: 6 or 7
60
- cnt = int(LINE_CNT_PER_TYPE.get(lt, 0))
61
- for i in range(1, cnt + 1):
62
- line_tuples.append((lt, i))
63
- return line_tuples
64
-
65
- # DISABLED: Module-level initialization was causing infinite loops
66
- # These variables are now created dynamically when needed
67
- # line_tuples=build_lines()
68
- # print("line_tuples",line_tuples)
69
- # PER_PRODUCT_SPEED = extract.read_package_speed_data() # Dynamic call
70
- # print("PER_PRODUCT_SPEED",PER_PRODUCT_SPEED)
71
-
72
- def sort_products_by_hierarchy(product_list):
73
- """
74
- Sort products by hierarchy levels and dependencies using topological sorting.
75
- Returns products in optimal production order: prepacks β†’ subkits β†’ masters
76
- Dependencies within the same level are properly ordered.
77
- """
78
- from collections import defaultdict, deque
79
-
80
- # Filter products that are in our production list and have hierarchy data
81
- products_with_hierarchy = [p for p in product_list if p in KIT_LEVELS]
82
- products_without_hierarchy = [p for p in product_list if p not in KIT_LEVELS]
83
-
84
- if products_without_hierarchy:
85
- print(f"[HIERARCHY] Products without hierarchy data: {products_without_hierarchy}")
86
-
87
- # Build dependency graph for products in our list
88
- graph = defaultdict(list) # product -> [dependents]
89
- in_degree = defaultdict(int) # product -> number of dependencies
90
-
91
- # Initialize all products
92
- for product in products_with_hierarchy:
93
- in_degree[product] = 0
94
-
95
- # Build edges based on actual dependencies
96
- # KIT_DEPENDENCIES = {product: [dependencies]} - "What does THIS product need?"
97
- # graph = {dependency: [products]} - "What depends on THIS dependency?"
98
- #
99
- # Example transformation:
100
- # KIT_DEPENDENCIES = {'subkit_A': ['prepack_1'], 'master_B': ['subkit_A']}
101
- # After building: graph = {'prepack_1': ['subkit_A'], 'subkit_A': ['master_B']}
102
- # This means: prepack_1 is needed by subkit_A, subkit_A is needed by master_B
103
- #
104
- # Example:
105
- # 1. product='subkit_A', deps=['prepack_1']
106
- # β†’ graph['prepack_1'].append('subkit_A')
107
- # β†’ graph = {'prepack_1': ['subkit_A']}
108
- # 2. product='master_B', deps=['subkit_A']
109
- # β†’ graph['subkit_A'].append('master_B')
110
- # β†’ graph = {'prepack_1': ['subkit_A'], 'subkit_A': ['master_B']}
111
-
112
-
113
- for product in products_with_hierarchy:
114
- deps = KIT_DEPENDENCIES.get(product, []) #dependencies = products that has to be packed first
115
- for dep in deps:
116
- if dep in products_with_hierarchy: # Only if dependency is in our production list
117
- # REVERSE THE RELATIONSHIP:
118
- # KIT_DEPENDENCIES says: "product needs dep"
119
- # graph says: "dep is needed by product"
120
- graph[dep].append(product) # dep -> product (reverse the relationship!)
121
- in_degree[product] += 1
122
-
123
- # Topological sort with hierarchy level priority
124
- sorted_products = []
125
- #queue = able to remove from both sides
126
- queue = deque()
127
-
128
- # Start with products that have no dependencies
129
- for product in products_with_hierarchy:
130
- if in_degree[product] == 0:
131
- queue.append(product)
132
-
133
- while queue:
134
- current = queue.popleft()
135
- sorted_products.append(current)
136
-
137
- # Process dependents - sort by hierarchy level first
138
- for dependent in sorted(graph[current], key=lambda p: (KIT_LEVELS.get(p, 999), p)):
139
- in_degree[dependent] -= 1 #decrement the in_degree of the dependent
140
- if in_degree[dependent] == 0: #if the in_degree of the dependent is 0, add it to the queue so that it can be processed
141
- queue.append(dependent)
142
-
143
- # Check for cycles (shouldn't happen with proper hierarchy)
144
- if len(sorted_products) != len(products_with_hierarchy):
145
- remaining = [p for p in products_with_hierarchy if p not in sorted_products]
146
- print(f"[HIERARCHY] WARNING: Potential circular dependencies detected in: {remaining}")
147
- # Add remaining products sorted by level as fallback
148
- remaining_sorted = sorted(remaining, key=lambda p: (KIT_LEVELS.get(p, 999), p))
149
- sorted_products.extend(remaining_sorted)
150
-
151
- # Add products without hierarchy information at the end
152
- sorted_products.extend(sorted(products_without_hierarchy))
153
-
154
- print(f"[HIERARCHY] Dependency-aware production order: {len(sorted_products)} products")
155
- for i, p in enumerate(sorted_products[:10]): # Show first 10
156
- level = KIT_LEVELS.get(p, "unknown")
157
- level_name = KitLevel.get_name(level)
158
- deps = KIT_DEPENDENCIES.get(p, [])
159
- deps_in_list = [d for d in deps if d in products_with_hierarchy]
160
- print(f" {i+1}. {p} (level {level}={level_name}, deps: {len(deps_in_list)})")
161
- if deps_in_list:
162
- print(f" Dependencies: {deps_in_list}")
163
-
164
- if len(sorted_products) > 10:
165
- print(f" ... and {len(sorted_products) - 10} more products")
166
-
167
- return sorted_products
168
-
169
- # Removed get_dependency_timing_weight function - no longer needed
170
- # Dependency ordering is now handled by topological sorting in sort_products_by_hierarchy()
171
-
172
- def run_optimization_for_week():
173
- # Load hierarchy data lazily at the start of optimization
174
- global KIT_LEVELS, KIT_DEPENDENCIES, PRODUCTION_PRIORITY_ORDER, KIT_LINE_MATCH_DICT
175
- KIT_LEVELS = get_kit_levels()
176
- KIT_DEPENDENCIES = get_kit_dependencies()
177
- PRODUCTION_PRIORITY_ORDER = get_production_priority_order()
178
- KIT_LINE_MATCH_DICT = get_kit_line_match()
179
-
180
- # *** CRITICAL: Load fresh data to reflect current Streamlit configs ***
181
- print("\n" + "="*60)
182
- print("πŸ”„ LOADING FRESH DATA FOR OPTIMIZATION")
183
- print("="*60)
184
-
185
- # Get fresh product list and demand data
186
- PRODUCT_LIST = get_product_list()
187
- DEMAND_DICTIONARY = get_demand_dictionary()
188
- TEAM_REQ_PER_PRODUCT = get_team_requirements(PRODUCT_LIST)
189
-
190
- # Get date span dynamically
191
- DATE_SPAN, start_date, end_date = get_date_span()
192
-
193
- print(f"πŸ“¦ LOADED PRODUCTS: {len(PRODUCT_LIST)} products")
194
- print(f"πŸ“ˆ LOADED DEMAND: {sum(DEMAND_DICTIONARY.values())} total units")
195
- print(f"πŸ‘₯ LOADED TEAM REQUIREMENTS: {len(TEAM_REQ_PER_PRODUCT)} employee types")
196
-
197
- # Build ACTIVE schedule for fresh product list
198
- ACTIVE = {t: {p: 1 for p in PRODUCT_LIST} for t in DATE_SPAN}
199
-
200
- # --- Sets ---
201
- date_span_list = list(DATE_SPAN)
202
- # print("date_span_list",date_span_list)
203
- active_shift_list = sorted(list(get_active_shift_list())) # Dynamic call
204
- employee_type_list = list(get_employee_type_list()) # Dynamic call - e.g., ['UNICEF Fixed term','Humanizer']
205
- print("employee_type_list",employee_type_list)
206
- # *** HIERARCHY SORTING: Sort products by production priority ***
207
- print("\n" + "="*60)
208
- print("πŸ”— APPLYING HIERARCHY-BASED PRODUCTION ORDERING")
209
- print("="*60)
210
- sorted_product_list = sort_products_by_hierarchy(list(PRODUCT_LIST))
211
-
212
- line_tuples = build_lines()
213
- print("Lines",line_tuples)
214
-
215
- # Load product speed data dynamically
216
- # Import extract module for direct access to data functions
217
- from src.preprocess import extract
218
- PER_PRODUCT_SPEED = extract.read_package_speed_data()
219
- print("PER_PRODUCT_SPEED",PER_PRODUCT_SPEED)
220
-
221
- # --- Short aliases for parameters ---
222
- Hmax_s = dict(get_max_hour_per_shift_per_person()) # Dynamic call - per-shift hours
223
- Hmax_daily = MAX_HOUR_PER_PERSON_PER_DAY # {6:cap, 7:cap}
224
- max_workers_line = dict(get_max_parallel_workers()) # Dynamic call - per line type
225
- max_employee_type_day = get_max_employee_per_type_on_day() # Dynamic call - {emp_type:{t:headcount}}
226
- cost = get_cost_list_per_emp_shift() # Dynamic call - {emp_type:{shift:cost}}
227
- # --- Feasibility quick checks ---
228
-
229
- # 1) If team size is greater than max_workers_line, block the product-line type combination
230
- for p in sorted_product_list:
231
- req_total = sum(TEAM_REQ_PER_PRODUCT[e][p] for e in employee_type_list)
232
- lt = KIT_LINE_MATCH_DICT.get(p, 6) # Default to long line (6) if not found
233
- if p not in KIT_LINE_MATCH_DICT:
234
- print(f"[WARN] Product {p}: No line type mapping found, defaulting to long line (6)")
235
- if req_total > max_workers_line.get(lt, 1e9):
236
- print(f"[WARN] Product {p}: team size {req_total} > MAX_PARALLEL_WORKERS[{lt}] "
237
- f"= {max_workers_line.get(lt)}. Blocked.")
238
-
239
- # 2) Check if demand can be met without evening shift (only if in normal mode)
240
- if EVENING_SHIFT_MODE == "normal":
241
- total_demand = sum(DEMAND_DICTIONARY.get(p, 0) for p in sorted_product_list)
242
-
243
- # Calculate maximum capacity with regular + overtime shifts only
244
- regular_overtime_shifts = [s for s in active_shift_list if s in ShiftType.REGULAR_AND_OVERTIME]
245
- max_capacity = 0
246
 
247
- for p in sorted_product_list:
248
- if p in PER_PRODUCT_SPEED:
249
- product_speed = PER_PRODUCT_SPEED[p] # units per hour
250
- # Calculate max hours available for this product across all lines and shifts
251
- max_hours_per_product = 0
252
- for ell in line_tuples:
253
- for s in regular_overtime_shifts:
254
- for t in date_span_list:
255
- max_hours_per_product += Hmax_s[s]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
- max_capacity += product_speed * max_hours_per_product
 
 
 
 
258
 
259
- capacity_ratio = max_capacity / total_demand if total_demand > 0 else float('inf')
 
 
 
 
260
 
261
- print(f"[CAPACITY CHECK] Total demand: {total_demand}")
262
- print(f"[CAPACITY CHECK] Max capacity (Regular + Overtime): {max_capacity:.1f}")
263
- print(f"[CAPACITY CHECK] Capacity ratio: {capacity_ratio:.2f}")
 
 
264
 
265
- if capacity_ratio < EVENING_SHIFT_DEMAND_THRESHOLD:
266
- print(f"\n🚨 [ALERT] DEMAND TOO HIGH!")
267
- print(f" Current capacity can only meet {capacity_ratio*100:.1f}% of demand")
268
- print(f" Threshold: {EVENING_SHIFT_DEMAND_THRESHOLD*100:.1f}%")
269
- print(f" RECOMMENDATION: Change EVENING_SHIFT_MODE to 'activate_evening' to enable evening shift")
270
- print(f" This will add shift 3 to increase capacity\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
- # --- Solver ---
274
- solver = pywraplp.Solver.CreateSolver('CBC')
275
- if not solver:
276
- raise RuntimeError("CBC solver not found.")
277
- INF = solver.infinity()
278
 
279
- # --- Variables ---
280
- # Assignment[p,ell,s,t] ∈ {0,1}: 1 if product p runs on (line,shift,day)
281
- Assignment, Hours, Units = {}, {}, {} # Hours: run hours, Units: production units
282
- for p in sorted_product_list:
283
- for ell in line_tuples: # ell = (line_type_id, idx)
 
 
 
284
  for s in active_shift_list:
285
  for t in date_span_list:
286
- #Is product p assigned to run on line ell, during shift s, on day t?
287
- Assignment[p, ell, s, t] = solver.BoolVar(f"Z_{p}_{ell[0]}_{ell[1]}_s{s}_d{t}")
288
- #How many hours does product p run on line ell, during shift s, on day t?
289
- Hours[p, ell, s, t] = solver.NumVar(0, Hmax_s[s], f"T_{p}_{ell[0]}_{ell[1]}_s{s}_d{t}")
290
- #How many units does product p run on line ell, during shift s, on day t?
291
- Units[p, ell, s, t] = solver.NumVar(0, INF, f"U_{p}_{ell[0]}_{ell[1]}_s{s}_d{t}")
292
-
293
- # Note: IDLE variables removed - we only track employees actually working on production
294
-
295
- # Load fixed minimum UNICEF requirement (needed for EMPLOYEE_COUNT variable creation)
296
- FIXED_MIN_UNICEF_PER_DAY = get_fixed_min_unicef_per_day() # Dynamic call
297
-
298
- # Variable to track actual number of employees of each type working each shift each day
299
- # This represents how many distinct employees of type e are working in shift s on day t
300
- EMPLOYEE_COUNT = {}
301
- for e in employee_type_list:
302
- for s in active_shift_list:
303
- for t in date_span_list:
304
- # Note: Minimum staffing is per day, not per shift
305
- # We'll handle the daily minimum constraint separately
306
- max_count = max_employee_type_day.get(e, {}).get(t, 100)
307
- EMPLOYEE_COUNT[e, s, t] = solver.IntVar(
308
- 0, # No minimum per shift (daily minimum handled separately)
309
- max_count,
310
- f"EmpCount_{e}_s{s}_day{t}"
311
- )
312
-
313
- # Track total person-hours worked by each employee type per shift per day
314
- # This is needed for employee-centric wage calculation
315
- EMPLOYEE_HOURS = {}
316
- for e in employee_type_list:
317
- for s in active_shift_list:
318
- for t in date_span_list:
319
- # Sum of all work hours for employee type e in shift s on day t
320
- # This represents total person-hours (e.g., 5 employees Γ— 8 hours = 40 person-hours)
321
- EMPLOYEE_HOURS[e, s, t] = solver.Sum(
322
- TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t]
323
- for p in sorted_product_list
324
- for ell in line_tuples
325
- )
326
-
327
- # Note: Binary variables for bulk payment are now created inline in the cost calculation
328
-
329
- # --- Objective: Minimize total labor cost (wages) ---
330
- # Employee-centric approach: calculate wages based on actual employees and their hours
331
- PAYMENT_MODE_CONFIG = get_payment_mode_config() # Dynamic call
332
- print(f"Payment mode configuration: {PAYMENT_MODE_CONFIG}")
333
-
334
- # Build cost terms based on payment mode
335
- cost_terms = []
336
-
337
- for e in employee_type_list:
338
- for s in active_shift_list:
339
- for t in date_span_list:
340
- payment_mode = PAYMENT_MODE_CONFIG.get(s, "partial") # Default to partial if not specified
341
-
342
- if payment_mode == "partial":
343
- # Partial payment: pay for actual person-hours worked
344
- # Cost = hourly_rate Γ— total_person_hours
345
- # Example: $20/hr Γ— 40 person-hours = $800
346
- cost_terms.append(cost[e][s] * EMPLOYEE_HOURS[e, s, t])
347
-
348
- elif payment_mode == "bulk":
349
- # Bulk payment: if ANY work happens in shift, pay ALL working employees for FULL shift
350
- # We need to know: did employee type e work at all in shift s on day t?
351
-
352
- # Create binary: 1 if employee type e worked in this shift
353
- work_in_shift = solver.BoolVar(f"work_{e}_s{s}_d{t}")
354
-
355
- # Link binary to work hours
356
- # If EMPLOYEE_HOURS > 0, then work_in_shift = 1
357
- # If EMPLOYEE_HOURS = 0, then work_in_shift = 0
358
- max_possible_hours = Hmax_s[s] * max_employee_type_day[e][t]
359
- solver.Add(EMPLOYEE_HOURS[e, s, t] <= max_possible_hours * work_in_shift)
360
- solver.Add(work_in_shift * 0.001 <= EMPLOYEE_HOURS[e, s, t])
361
 
362
- # Calculate number of employees working in this shift
363
- # This is approximately: ceil(EMPLOYEE_HOURS / Hmax_s[s])
364
- # But we can use: employees_working_in_shift
365
- # For simplicity, use EMPLOYEE_HOURS / Hmax_s[s] as continuous approximation
366
- # Or better: create a variable for employees per shift
367
 
368
- # Simpler approach: For bulk payment, assume if work happens,
369
- # we need approximately EMPLOYEE_HOURS/Hmax_s[s] employees,
370
- # and each gets paid for full shift
371
- # Cost β‰ˆ (EMPLOYEE_HOURS / Hmax_s[s]) Γ— Hmax_s[s] Γ— hourly_rate = EMPLOYEE_HOURS Γ— hourly_rate
372
- # But that's the same as partial! The difference is we round up employees.
373
-
374
- # Better approach: Create variable for employees working in this specific shift
375
- employees_in_shift = solver.IntVar(0, max_employee_type_day[e][t], f"emp_{e}_s{s}_d{t}")
376
-
377
- # Link employees_in_shift to work requirements
378
- # If EMPLOYEE_HOURS requires N employees, then employees_in_shift >= ceil(N)
379
- solver.Add(employees_in_shift * Hmax_s[s] >= EMPLOYEE_HOURS[e, s, t])
380
-
381
- # Cost: pay each employee for full shift
382
- cost_terms.append(cost[e][s] * Hmax_s[s] * employees_in_shift)
383
-
384
- # Note: No idle employee costs - only pay for employees actually working
385
-
386
- total_cost = solver.Sum(cost_terms)
387
-
388
- # Objective: minimize total labor cost (wages)
389
- # This finds the optimal production schedule (product order, line assignment, timing)
390
- # that minimizes total wages while meeting all demand and capacity constraints
391
- solver.Minimize(total_cost)
392
-
393
- # --- Constraints ---
394
-
395
- # 1) Weekly demand - must meet exactly (no over/under production)
396
- for p in sorted_product_list:
397
- total_production = solver.Sum(Units[p, ell, s, t] for ell in line_tuples for s in active_shift_list for t in date_span_list)
398
- demand = DEMAND_DICTIONARY.get(p, 0)
 
 
 
 
 
 
399
 
400
- # Must produce at least the demand
401
- solver.Add(total_production >= demand)
402
 
403
- # Must not produce more than the demand (prevent overproduction)
404
- solver.Add(total_production <= demand)
 
 
405
 
406
- # 2) One product per (line,shift,day) + time gating
407
- for ell in line_tuples:
408
- for s in active_shift_list:
409
- for t in date_span_list:
410
- solver.Add(solver.Sum(Assignment[p, ell, s, t] for p in sorted_product_list) <= 1)
411
- for p in sorted_product_list:
412
- solver.Add(Hours[p, ell, s, t] <= Hmax_s[s] * Assignment[p, ell, s, t])
413
-
414
- # 3) Product-line type compatibility + (optional) activity by day
415
- for p in sorted_product_list:
416
- req_lt = KIT_LINE_MATCH_DICT.get(p, LineType.LONG_LINE) # Default to long line if not found
417
- req_total = sum(TEAM_REQ_PER_PRODUCT[e][p] for e in employee_type_list)
 
 
418
  for ell in line_tuples:
419
- allowed = (ell[0] == req_lt) and (req_total <= max_workers_line.get(ell[0], 1e9))
420
  for s in active_shift_list:
421
  for t in date_span_list:
422
- if ACTIVE[t][p] == 0 or not allowed:
423
- solver.Add(Assignment[p, ell, s, t] == 0)
424
- solver.Add(Hours[p, ell, s, t] == 0)
425
- solver.Add(Units[p, ell, s, t] == 0)
426
 
427
- # 4) Line throughput: Units ≀ product_speed * Hours
428
- for p in sorted_product_list:
429
- for ell in line_tuples:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  for s in active_shift_list:
431
  for t in date_span_list:
432
- # Get product speed (same speed regardless of line type)
433
- if p in PER_PRODUCT_SPEED:
434
- # Convert kit per day to kit per hour (assuming 7.5 hour workday)
435
- speed = PER_PRODUCT_SPEED[p]
436
- # Upper bound: units cannot exceed capacity
437
- solver.Add(
438
- Units[p, ell, s, t] <= speed * Hours[p, ell, s, t]
439
- )
440
- # Lower bound: if working, must produce (prevent phantom work)
441
- solver.Add(
442
- Units[p, ell, s, t] >= speed * Hours[p, ell, s, t]
443
- )
444
- else:
445
- # Default speed if not found
446
- default_speed = 800 / 7.5 # units per hour
447
- print(f"Warning: No speed data for product {p}, using default {default_speed:.1f} per hour")
448
- # Upper bound: units cannot exceed capacity
449
- solver.Add(
450
- Units[p, ell, s, t] <= default_speed * Hours[p, ell, s, t]
451
- )
452
- # Lower bound: if working, must produce (prevent phantom work)
453
- solver.Add(
454
- Units[p, ell, s, t] >= default_speed * Hours[p, ell, s, t]
455
- )
456
 
457
- # Working hours constraint: active employees cannot exceed shift hour capacity
458
- for e in employee_type_list:
459
- for s in active_shift_list:
460
- for t in date_span_list:
461
- # No idle employee constraints - employees are only counted when working
462
- solver.Add(
463
- solver.Sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t] for p in sorted_product_list for ell in line_tuples)
464
- <= Hmax_s[s] * max_employee_type_day[e][t]
465
- )
466
-
467
- # 6) Per-shift staffing capacity by type: link employee count to actual work hours
468
- # This constraint ensures EMPLOYEE_COUNT[e,s,t] represents the actual number of employees needed in each shift
469
- for e in employee_type_list:
470
- for s in active_shift_list:
471
- for t in date_span_list:
472
- # Total person-hours worked by employee type e in shift s on day t
473
- total_person_hours_in_shift = solver.Sum(
474
- TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t]
475
- for p in sorted_product_list
476
- for ell in line_tuples
477
- )
478
-
479
- # Employee count must be sufficient to cover the work in this shift
480
- # If employees work H person-hours total and each can work max M hours/shift,
481
- # then we need at least ceil(H/M) employees
482
- # Constraint: employee_count Γ— max_hours_per_shift >= total_person_hours_in_shift
483
- solver.Add(EMPLOYEE_COUNT[e, s, t] * Hmax_s[s] >= total_person_hours_in_shift)
484
-
485
- # 7) Shift ordering constraints (only apply if shifts are available)
486
- # Evening shift after regular shift
487
- if ShiftType.EVENING in active_shift_list and ShiftType.REGULAR in active_shift_list: # Only if both shifts are available
488
  for e in employee_type_list:
489
- for t in date_span_list:
490
- solver.Add(
491
- solver.Sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.EVENING, t] for p in sorted_product_list for ell in line_tuples)
492
- <=
493
- solver.Sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.REGULAR, t] for p in sorted_product_list for ell in line_tuples)
494
- )
495
-
496
- # Overtime should only be used when regular shift is at capacity
497
- if ShiftType.OVERTIME in active_shift_list and ShiftType.REGULAR in active_shift_list: # Only if both shifts are available
498
- print("\n[OVERTIME] Adding constraints to ensure overtime only when regular shift is insufficient...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
 
500
- for e in employee_type_list:
501
- for t in date_span_list:
502
- # Get available regular capacity for this employee type and day
503
- regular_capacity = max_employee_type_day[e][t]
504
-
505
- # Total regular shift usage for this employee type and day
506
- regular_usage = solver.Sum(
507
- TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.REGULAR, t]
508
- for p in sorted_product_list for ell in line_tuples
509
- )
510
-
511
- # Total overtime usage for this employee type and day
512
- overtime_usage = solver.Sum(
513
- TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.OVERTIME, t]
514
- for p in sorted_product_list for ell in line_tuples
515
- )
516
-
517
- # Create binary variable: 1 if using overtime, 0 otherwise
518
- using_overtime = solver.IntVar(0, 1, f'using_overtime_{e}_{t}')
519
-
520
- # If using overtime, regular capacity must be utilized significantly
521
- # Regular usage must be at least 90% of capacity to allow overtime
522
- min_regular_for_overtime = int(0.9 * regular_capacity)
523
-
524
- # Constraint 1: Can only use overtime if regular usage is high
525
- solver.Add(regular_usage >= min_regular_for_overtime * using_overtime)
526
-
527
- # Constraint 2: If any overtime is used, set the binary variable
528
- solver.Add(overtime_usage <= regular_capacity * using_overtime)
529
-
530
- overtime_constraints_added = len(employee_type_list) * len(date_span_list) * 2 # 2 constraints per employee type per day
531
- print(f"[OVERTIME] Added {overtime_constraints_added} constraints ensuring overtime only when regular shifts are at 90%+ capacity")
532
-
533
- # 7.5) Bulk payment linking constraints are now handled inline in the cost calculation
534
-
535
- # 7.6) *** FIXED MINIMUM UNICEF EMPLOYEES CONSTRAINT ***
536
- # Ensure minimum UNICEF fixed-term staff work in the REGULAR shift every day
537
- # The minimum applies to the regular shift specifically (not overtime or evening)
538
- if 'UNICEF Fixed term' in employee_type_list and FIXED_MIN_UNICEF_PER_DAY > 0:
539
- if ShiftType.REGULAR in active_shift_list:
540
- print(f"\n[FIXED STAFFING] Adding constraint for minimum {FIXED_MIN_UNICEF_PER_DAY} UNICEF employees in REGULAR shift per day...")
541
- for t in date_span_list:
542
- # At least FIXED_MIN_UNICEF_PER_DAY employees must work in the regular shift each day
543
- solver.Add(
544
- EMPLOYEE_COUNT['UNICEF Fixed term', ShiftType.REGULAR, t] >= FIXED_MIN_UNICEF_PER_DAY
545
- )
546
- print(f"[FIXED STAFFING] Added {len(date_span_list)} constraints ensuring >= {FIXED_MIN_UNICEF_PER_DAY} UNICEF employees in regular shift per day")
547
- else:
548
- print(f"\n[FIXED STAFFING] Warning: Regular shift not available, cannot enforce minimum UNICEF staffing")
549
-
550
- # 8) *** HIERARCHY DEPENDENCY CONSTRAINTS ***
551
- # For subkits with prepack dependencies: dependencies should be produced before or same time
552
- print("\n[HIERARCHY] Adding dependency constraints...")
553
- dependency_constraints_added = 0
554
-
555
- for p in sorted_product_list:
556
- dependencies = KIT_DEPENDENCIES.get(p, [])
557
- if dependencies:
558
- # Get the level of the current product
559
- p_level = KIT_LEVELS.get(p, 2)
560
 
561
- for dep in dependencies:
562
- if dep in sorted_product_list: # Only if dependency is also in production list
563
- # Calculate "completion time" for each product (sum of all production times)
564
- p_completion = solver.Sum(
565
- t * Hours[p, ell, s, t] for ell in line_tuples for s in active_shift_list for t in date_span_list
 
 
 
 
566
  )
567
- dep_completion = solver.Sum(
568
- t * Hours[dep, ell, s, t] for ell in line_tuples for s in active_shift_list for t in date_span_list
 
 
 
569
  )
570
 
571
- # Dependency should complete before or at the same time
572
- solver.Add(dep_completion <= p_completion)
573
- dependency_constraints_added += 1
574
 
575
- print(f" Added constraint: {dep} (dependency) <= {p} (level {p_level})")
576
-
577
- print(f"[HIERARCHY] Added {dependency_constraints_added} dependency constraints")
578
-
579
- # --- Solve ---
580
- status = solver.Solve()
581
- if status != pywraplp.Solver.OPTIMAL:
582
- status_names = {pywraplp.Solver.INFEASIBLE: "INFEASIBLE", pywraplp.Solver.UNBOUNDED: "UNBOUNDED"}
583
- print(f"No optimal solution. Status: {status} ({status_names.get(status, 'UNKNOWN')})")
584
- # Debug hint:
585
- # solver.EnableOutput()
586
- # solver.ExportModelAsLpFile("model.lp")
587
- return None
588
-
589
- # --- Report ---
590
- result = {}
591
- result['objective'] = solver.Objective().Value()
592
-
593
- # Weekly production
594
- prod_week = {p: sum(Units[p, ell, s, t].solution_value() for ell in line_tuples for s in active_shift_list for t in date_span_list) for p in sorted_product_list}
595
- result['weekly_production'] = prod_week
596
-
597
- # Which product ran on which line/shift/day
598
- schedule = []
599
- for t in date_span_list:
600
- for ell in line_tuples:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  for s in active_shift_list:
602
- chosen = [p for p in sorted_product_list if Assignment[p, ell, s, t].solution_value() > 0.5]
603
- if chosen:
604
- p = chosen[0]
605
- schedule.append({
606
- 'day': t,
607
- 'line_type_id': ell[0],
608
- 'line_idx': ell[1],
609
- 'shift': s,
610
- 'product': p,
611
- 'run_hours': Hours[p, ell, s, t].solution_value(),
612
- 'units': Units[p, ell, s, t].solution_value(),
613
- })
614
- result['run_schedule'] = schedule
615
-
616
- # Implied headcount by type/shift/day (ceil)
617
- headcount = []
618
- for e in employee_type_list:
619
- for s in active_shift_list:
620
  for t in date_span_list:
621
- used_ph = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value() for p in sorted_product_list for ell in line_tuples)
622
- need = ceil(used_ph / (Hmax_s[s] + 1e-9))
623
- headcount.append({'emp_type': e, 'shift': s, 'day': t,
624
- 'needed': need, 'available': max_employee_type_day[e][t]})
625
- result['headcount_per_shift'] = headcount
626
-
627
- # Total person-hours by type/day (≀ 14h * headcount)
628
- ph_by_day = []
629
- for e in employee_type_list:
630
- for t in date_span_list:
631
- used = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value() for s in active_shift_list for p in sorted_product_list for ell in line_tuples)
632
- ph_by_day.append({'emp_type': e, 'day': t,
633
- 'used_person_hours': used,
634
- 'cap_person_hours': Hmax_daily * max_employee_type_day[e][t]})
635
- result['person_hours_by_day'] = ph_by_day
636
-
637
- # Actual employee count per type/shift/day (from EMPLOYEE_COUNT variable)
638
- employee_count_by_shift = []
639
- for e in employee_type_list:
640
- for s in active_shift_list:
 
 
 
 
 
 
 
 
 
 
641
  for t in date_span_list:
642
- count = int(EMPLOYEE_COUNT[e, s, t].solution_value())
 
643
  used_hours = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value()
644
- for p in sorted_product_list for ell in line_tuples)
645
- avg_hours_per_employee = used_hours / count if count > 0 else 0
646
- if count > 0: # Only add entries where employees are working
647
- employee_count_by_shift.append({
648
  'emp_type': e,
649
- 'shift': s,
650
  'day': t,
651
- 'employee_count': count,
652
  'total_person_hours': used_hours,
653
  'avg_hours_per_employee': avg_hours_per_employee,
654
  'available': max_employee_type_day[e][t]
655
  })
656
- result['employee_count_by_shift'] = employee_count_by_shift
657
-
658
- # Also calculate daily totals (summing across shifts)
659
- employee_count_by_day = []
660
- for e in employee_type_list:
661
- for t in date_span_list:
662
- # Sum employees across all shifts for this day
663
- total_count = sum(int(EMPLOYEE_COUNT[e, s, t].solution_value()) for s in active_shift_list)
664
- used_hours = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value()
665
- for s in active_shift_list for p in sorted_product_list for ell in line_tuples)
666
- avg_hours_per_employee = used_hours / total_count if total_count > 0 else 0
667
- if total_count > 0: # Only add days where employees are working
668
- employee_count_by_day.append({
669
- 'emp_type': e,
670
- 'day': t,
671
- 'employee_count': total_count,
672
- 'total_person_hours': used_hours,
673
- 'avg_hours_per_employee': avg_hours_per_employee,
674
- 'available': max_employee_type_day[e][t]
675
- })
676
- result['employee_count_by_day'] = employee_count_by_day
677
-
678
- # Note: Idle employee tracking removed - only counting employees actually working
679
-
680
- # Pretty print
681
- print("Objective (min cost):", result['objective'])
682
- print("\n--- Weekly production by product ---")
683
- for p, u in prod_week.items():
684
- print(f"{p}: {u:.1f} / demand {DEMAND_DICTIONARY.get(p,0)}")
685
-
686
- print("\n--- Schedule (line, shift, day) ---")
687
- for row in schedule:
688
- shift_name = ShiftType.get_name(row['shift'])
689
- line_name = LineType.get_name(row['line_type_id'])
690
- print(f"date_span_list{row['day']} {line_name}-{row['line_idx']} {shift_name}: "
691
- f"{row['product']} Hours={row['run_hours']:.2f}h Units={row['units']:.1f}")
692
-
693
- print("\n--- Implied headcount need (per type/shift/day) ---")
694
- for row in headcount:
695
- shift_name = ShiftType.get_name(row['shift'])
696
- print(f"{row['emp_type']}, {shift_name}, date_span_list{row['day']}: "
697
- f"need={row['needed']} (avail {row['available']})")
698
-
699
- print("\n--- Total person-hours by type/day ---")
700
- for row in ph_by_day:
701
- print(f"{row['emp_type']}, date_span_list{row['day']}: used={row['used_person_hours']:.1f} "
702
- f"(cap {row['cap_person_hours']})")
703
-
704
- print("\n--- Actual employee count by type/shift/day ---")
705
- for row in employee_count_by_shift:
706
- shift_name = ShiftType.get_name(row['shift'])
707
- print(f"{row['emp_type']}, {shift_name}, date_span_list{row['day']}: "
708
- f"count={row['employee_count']} employees, "
709
- f"total_hours={row['total_person_hours']:.1f}h, "
710
- f"avg={row['avg_hours_per_employee']:.1f}h/employee")
711
-
712
- print("\n--- Daily employee totals by type/day (sum across shifts) ---")
713
- for row in employee_count_by_day:
714
- print(f"{row['emp_type']}, date_span_list{row['day']}: "
715
- f"count={row['employee_count']} employees total, "
716
- f"total_hours={row['total_person_hours']:.1f}h, "
717
- f"avg={row['avg_hours_per_employee']:.1f}h/employee "
718
- f"(available: {row['available']})")
719
 
720
- # Note: Idle employee reporting removed - only tracking employees actually working
721
 
722
- return result
723
 
724
 
725
  if __name__ == "__main__":
726
- run_optimization_for_week()
 
 
9
 
10
  from ortools.linear_solver import pywraplp
11
  from math import ceil
12
+ import datetime
13
  from src.config.constants import ShiftType, LineType, KitLevel
14
 
15
  # ---- config import ----
16
+ # Import constants and other modules directly
17
+ from src.config.constants import ShiftType, LineType, DefaultConfig
18
+ import src.preprocess.extract as extract
19
+ from src.preprocess.hierarchy_parser import sort_products_by_hierarchy
20
+
21
+ class Optimizer:
22
+ """Workforce optimization class that handles all configuration and optimization logic"""
23
+
24
+ def __init__(self):
25
+ """Initialize optimizer with session state configuration"""
26
+ self.load_session_state_config()
27
+ self.load_data()
28
+
29
+ def load_session_state_config(self):
30
+ """Load all configuration from session state"""
31
+ import streamlit as st
32
+ import datetime as dt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ # Date configuration
35
+ self.start_date = st.session_state.start_date
36
+ self.planning_days = st.session_state.planning_days
37
+ self.start_datetime = dt.datetime.combine(self.start_date, dt.datetime.min.time())
38
+ self.end_date = self.start_datetime + dt.timedelta(days=self.planning_days - 1)
39
+ self.date_span = list(range(1, self.planning_days + 1))
40
+
41
+ # Employee and shift configuration
42
+ self.employee_type_list = list(st.session_state.selected_employee_types)
43
+ self.active_shift_list = sorted(list(st.session_state.selected_shifts))
44
+
45
+ print("\n[DEBUG] From session_state.selected_employee_types:")
46
+ for emp in self.employee_type_list:
47
+ print(f" - '{emp}' (len={len(emp)}, repr={repr(emp)})")
48
+
49
+ # Working hours configuration
50
+ self.max_hour_per_person_per_day = st.session_state.max_hour_per_person_per_day
51
+ self.max_hours_shift = {
52
+ ShiftType.REGULAR: st.session_state.max_hours_shift_1,
53
+ ShiftType.EVENING: st.session_state.max_hours_shift_2,
54
+ ShiftType.OVERTIME: st.session_state.max_hours_shift_3
55
+ }
56
+
57
+ # Workforce limits
58
+ self.max_employee_per_type_on_day = st.session_state.max_employee_per_type_on_day
59
+
60
+ # Operations configuration
61
+ self.line_counts = st.session_state.line_counts
62
+ self.max_parallel_workers = {
63
+ LineType.LONG_LINE: st.session_state.max_parallel_workers_long_line,
64
+ LineType.MINI_LOAD: st.session_state.max_parallel_workers_mini_load
65
+ }
66
+
67
+ # Cost configuration
68
+ self.cost_list_per_emp_shift = st.session_state.cost_list_per_emp_shift
69
+
70
+ # Payment mode configuration
71
+ self.payment_mode_config = st.session_state.payment_mode_config
72
+
73
+ # Fixed staffing requirements
74
+ self.fixed_min_unicef_per_day = st.session_state.fixed_min_unicef_per_day
75
+
76
+ print("βœ… Session state configuration loaded successfully")
77
+
78
+ def load_data(self):
79
+ """Load all required data from files"""
80
+ # Load hierarchy data
81
+ try:
82
+ kit_levels, dependencies, priority_order = extract.get_production_order_data()
83
+ self.kit_levels = kit_levels
84
+ self.kit_dependencies = dependencies
85
+ self.production_priority_order = priority_order
86
+ except:
87
+ self.kit_levels = {}
88
+ self.kit_dependencies = {}
89
+ self.production_priority_order = []
90
+
91
+ # Load kit line match data
92
+ try:
93
+ kit_line_match = extract.read_kit_line_match_data()
94
+ kit_line_match_dict = kit_line_match.set_index("kit_name")["line_type"].to_dict()
95
+
96
+ # Create line name to ID mapping
97
+ line_name_to_id = {
98
+ "long line": LineType.LONG_LINE,
99
+ "mini load": LineType.MINI_LOAD
100
+ }
101
+
102
+ # Convert line names to IDs
103
+ self.kit_line_match_dict = {}
104
+ for kit_name, line_name in kit_line_match_dict.items():
105
+ self.kit_line_match_dict[kit_name] = line_name_to_id.get(line_name.lower(), line_name)
106
+ except:
107
+ self.kit_line_match_dict = {}
108
+
109
+ # Load product and demand data
110
+ try:
111
+ from src.demand_filtering import DemandFilter
112
+ filter_instance = DemandFilter()
113
+ filter_instance.load_data(force_reload=True)
114
+ self.product_list = filter_instance.get_filtered_product_list()
115
+ self.demand_dictionary = filter_instance.get_filtered_demand_dictionary()
116
+ except:
117
+ self.product_list = []
118
+ self.demand_dictionary = {}
119
+
120
+ # Load team requirements
121
+ try:
122
+ print("\n[DEBUG] Loading team requirements from Kits Calculation...")
123
+ kits_df = extract.read_personnel_requirement_data()
124
+ print(f"[DEBUG] Loaded kits_df with {len(kits_df)} rows")
125
+ print(f"[DEBUG] Columns: {list(kits_df.columns)}")
126
+
127
+ # Initialize team requirements dictionary
128
+ self.team_req_per_product = {
129
+ "UNICEF Fixed term": {},
130
+ "Humanizer": {}
131
+ }
132
+
133
+ # Process each product in the product list
134
+ for product in self.product_list:
135
+ product_data = kits_df[kits_df['Kit'] == product]
136
+ if not product_data.empty:
137
+ # Extract Humanizer and UNICEF staff requirements
138
+ humanizer_req = product_data["Humanizer"].iloc[0]
139
+ unicef_req = product_data["UNICEF staff"].iloc[0]
140
+
141
+ # Convert to int (data is already cleaned in extract function)
142
+ self.team_req_per_product["Humanizer"][product] = int(humanizer_req)
143
+ self.team_req_per_product["UNICEF Fixed term"][product] = int(unicef_req)
144
+ else:
145
+ print(f"[WARN] Product {product} not found in Kits Calculation, setting requirements to 0")
146
+ self.team_req_per_product["Humanizer"][product] = 0
147
+ self.team_req_per_product["UNICEF Fixed term"][product] = 0
148
+
149
+ print(f"\n[DEBUG] team_req_per_product keys after loading:")
150
+ for key in self.team_req_per_product.keys():
151
+ product_count = len(self.team_req_per_product[key])
152
+ print(f" - '{key}' (len={len(key)}, {product_count} products)")
153
 
154
+ except Exception as e:
155
+ print(f"[ERROR] Failed to load team requirements: {e}")
156
+ import traceback
157
+ traceback.print_exc()
158
+ self.team_req_per_product = {}
159
 
160
+ # Load product speed data
161
+ try:
162
+ self.per_product_speed = extract.read_package_speed_data()
163
+ except:
164
+ self.per_product_speed = {}
165
 
166
+ print("βœ… All data loaded successfully")
167
+
168
+ def build_lines(self):
169
+ """Build line instances from session state configuration"""
170
+ line_tuples = []
171
 
172
+ try:
173
+ import streamlit as st
174
+ # Get selected line types from Data Selection tab
175
+ selected_lines = st.session_state.selected_lines
176
+ # Get line counts from Operations tab
177
+ line_counts = st.session_state.line_counts
178
+
179
+ print(f"Using lines from session state - selected: {selected_lines}, counts: {line_counts}")
180
+ for line_type in selected_lines:
181
+ count = line_counts.get(line_type, 0)
182
+ for i in range(1, count + 1):
183
+ line_tuples.append((line_type, i))
184
+
185
+ return line_tuples
186
+
187
+ except Exception as e:
188
+ print(f"Could not get line config from session state: {e}")
189
+ # Fallback: Use default values
190
+ print("Falling back to default line configuration")
191
+ default_selected_lines = [LineType.LONG_LINE, LineType.MINI_LOAD]
192
+ default_line_counts = {
193
+ LineType.LONG_LINE: DefaultConfig.LINE_COUNT_LONG_LINE,
194
+ LineType.MINI_LOAD: DefaultConfig.LINE_COUNT_MINI_LOAD
195
+ }
196
+
197
+ for line_type in default_selected_lines:
198
+ count = default_line_counts.get(line_type, 0)
199
+ for i in range(1, count + 1):
200
+ line_tuples.append((line_type, i))
201
+
202
+ return line_tuples
203
+
204
+ def run_optimization(self):
205
+ """Run the main optimization algorithm"""
206
+ # *** CRITICAL: Load fresh data to reflect current Streamlit configs ***
207
+ print("\n" + "="*60)
208
+ print("πŸ”„ LOADING FRESH DATA FOR OPTIMIZATION")
209
+ print("="*60)
210
+
211
+ print(f"πŸ“¦ LOADED PRODUCTS: {len(self.product_list)} products")
212
+ print(f"πŸ“ˆ LOADED DEMAND: {sum(self.demand_dictionary.values())} total units")
213
+ print(f"πŸ‘₯ LOADED TEAM REQUIREMENTS: {len(self.team_req_per_product)} employee types")
214
+
215
+ # Debug: Print team requirements keys
216
+ print("\n[DEBUG] team_req_per_product employee types:")
217
+ for emp_type in self.team_req_per_product.keys():
218
+ print(f" - '{emp_type}'")
219
+
220
+ print("\n[DEBUG] self.employee_type_list:")
221
+ for emp_type in self.employee_type_list:
222
+ print(f" - '{emp_type}'")
223
+
224
+ # Build ACTIVE schedule for fresh product list
225
+ ACTIVE = {t: {p: 1 for p in self.product_list} for t in self.date_span}
226
+
227
+ # --- Sets ---
228
+ date_span_list = list(self.date_span)
229
+ employee_type_list = self.employee_type_list
230
+ active_shift_list = self.active_shift_list
231
+ print(f"\n[DEBUG] employee_type_list: {employee_type_list}")
232
+ print(f"[DEBUG] active_shift_list: {active_shift_list}")
233
+
234
+ # *** HIERARCHY SORTING: Sort products by production priority ***
235
+ print("\n" + "="*60)
236
+ print("πŸ”— APPLYING HIERARCHY-BASED PRODUCTION ORDERING")
237
+ print("="*60)
238
+ sorted_product_list = sort_products_by_hierarchy(list(self.product_list), self.kit_levels, self.kit_dependencies)
239
+
240
+ line_tuples = self.build_lines()
241
+ print("Lines", line_tuples)
242
+
243
+ print("PER_PRODUCT_SPEED", self.per_product_speed)
244
+
245
+ # --- Short aliases for parameters ---
246
+ print("\n[DEBUG] Creating variable aliases...")
247
+ Hmax_s = dict(self.max_hours_shift) # per-shift hours
248
+ Hmax_daily = self.max_hour_per_person_per_day
249
+ max_workers_line = dict(self.max_parallel_workers) # per line type
250
+ max_employee_type_day = self.max_employee_per_type_on_day # {emp_type:{t:headcount}}
251
+ cost = self.cost_list_per_emp_shift # {emp_type:{shift:cost}}
252
+
253
+ # Create aliases for data dictionaries
254
+ TEAM_REQ_PER_PRODUCT = self.team_req_per_product
255
+ DEMAND_DICTIONARY = self.demand_dictionary
256
+ KIT_LINE_MATCH_DICT = self.kit_line_match_dict
257
+ KIT_LEVELS = self.kit_levels
258
+ KIT_DEPENDENCIES = self.kit_dependencies
259
+ PER_PRODUCT_SPEED = self.per_product_speed
260
+ FIXED_MIN_UNICEF_PER_DAY = self.fixed_min_unicef_per_day
261
+ PAYMENT_MODE_CONFIG = self.payment_mode_config
262
+
263
+ # Mock missing config variables (if they exist in config, they'll be overridden)
264
+ EVENING_SHIFT_MODE = "normal"
265
+ EVENING_SHIFT_DEMAND_THRESHOLD = 0.9
266
+
267
+ print(f"[DEBUG] TEAM_REQ_PER_PRODUCT has {len(TEAM_REQ_PER_PRODUCT)} employee types")
268
+ print(f"[DEBUG] employee_type_list has {len(employee_type_list)} types")
269
+
270
+ # --- Feasibility quick checks ---
271
+ print("\n[DEBUG] Starting feasibility checks...")
272
+
273
+ # 1) If team size is greater than max_workers_line, block the product-line type combination
274
+ for i, p in enumerate(sorted_product_list):
275
+ print(f"[DEBUG] Checking product {i+1}/{len(sorted_product_list)}: {p}")
276
+
277
+ # Check if all employee types exist in TEAM_REQ_PER_PRODUCT
278
+ for e in employee_type_list:
279
+ if e not in TEAM_REQ_PER_PRODUCT:
280
+ print(f"[ERROR] Employee type '{e}' not found in TEAM_REQ_PER_PRODUCT!")
281
+ print(f"[ERROR] Available keys: {list(TEAM_REQ_PER_PRODUCT.keys())}")
282
+ raise KeyError(f"Employee type '{e}' not in team requirements data")
283
+ if p not in TEAM_REQ_PER_PRODUCT[e]:
284
+ print(f"[ERROR] Product '{p}' not found in TEAM_REQ_PER_PRODUCT['{e}']!")
285
+ raise KeyError(f"Product '{p}' not in team requirements for employee type '{e}'")
286
+
287
+ req_total = sum(TEAM_REQ_PER_PRODUCT[e][p] for e in employee_type_list)
288
+ print(f"[DEBUG] req_total: {req_total}")
289
+ lt = KIT_LINE_MATCH_DICT.get(p, 6) # Default to long line (6) if not found
290
+ if p not in KIT_LINE_MATCH_DICT:
291
+ print(f"[WARN] Product {p}: No line type mapping found, defaulting to long line (6)")
292
+ if req_total > max_workers_line.get(lt, 1e9):
293
+ print(f"[WARN] Product {p}: team size {req_total} > MAX_PARALLEL_WORKERS[{lt}] "
294
+ f"= {max_workers_line.get(lt)}. Blocked.")
295
+
296
+ # 2) Check if demand can be met without evening shift (only if in normal mode)
297
+ if EVENING_SHIFT_MODE == "normal":
298
+ total_demand = sum(DEMAND_DICTIONARY.get(p, 0) for p in sorted_product_list)
299
+
300
+ # Calculate maximum capacity with regular + overtime shifts only
301
+ regular_overtime_shifts = [s for s in active_shift_list if s in ShiftType.REGULAR_AND_OVERTIME]
302
+ max_capacity = 0
303
+
304
+ for p in sorted_product_list:
305
+ if p in PER_PRODUCT_SPEED:
306
+ product_speed = PER_PRODUCT_SPEED[p] # units per hour
307
+ # Calculate max hours available for this product across all lines and shifts
308
+ max_hours_per_product = 0
309
+ for ell in line_tuples:
310
+ for s in regular_overtime_shifts:
311
+ for t in date_span_list:
312
+ max_hours_per_product += Hmax_s[s]
313
+
314
+ max_capacity += product_speed * max_hours_per_product
315
+
316
+ capacity_ratio = max_capacity / total_demand if total_demand > 0 else float('inf')
317
+
318
+ print(f"[CAPACITY CHECK] Total demand: {total_demand}")
319
+ print(f"[CAPACITY CHECK] Max capacity (Regular + Overtime): {max_capacity:.1f}")
320
+ print(f"[CAPACITY CHECK] Capacity ratio: {capacity_ratio:.2f}")
321
+
322
+ if capacity_ratio < EVENING_SHIFT_DEMAND_THRESHOLD:
323
+ print(f"\n🚨 [ALERT] DEMAND TOO HIGH!")
324
+ print(f" Current capacity can only meet {capacity_ratio*100:.1f}% of demand")
325
+ print(f" Threshold: {EVENING_SHIFT_DEMAND_THRESHOLD*100:.1f}%")
326
+ print(f" RECOMMENDATION: Change EVENING_SHIFT_MODE to 'activate_evening' to enable evening shift")
327
+ print(f" This will add shift 3 to increase capacity\n")
328
+
329
+
330
+ # --- Solver ---
331
+ solver = pywraplp.Solver.CreateSolver('CBC')
332
+ if not solver:
333
+ raise RuntimeError("CBC solver not found.")
334
+ INF = solver.infinity()
335
+
336
+ # --- Variables ---
337
+ # Assignment[p,ell,s,t] ∈ {0,1}: 1 if product p runs on (line,shift,day)
338
+ Assignment, Hours, Units = {}, {}, {} # Hours: run hours, Units: production units
339
+ for p in sorted_product_list:
340
+ for ell in line_tuples: # ell = (line_type_id, idx)
341
+ for s in active_shift_list:
342
+ for t in date_span_list:
343
+ #Is product p assigned to run on line ell, during shift s, on day t?
344
+ Assignment[p, ell, s, t] = solver.BoolVar(f"Z_{p}_{ell[0]}_{ell[1]}_s{s}_d{t}")
345
+ #How many hours does product p run on line ell, during shift s, on day t?
346
+ Hours[p, ell, s, t] = solver.NumVar(0, Hmax_s[s], f"T_{p}_{ell[0]}_{ell[1]}_s{s}_d{t}")
347
+ #How many units does product p run on line ell, during shift s, on day t?
348
+ Units[p, ell, s, t] = solver.NumVar(0, INF, f"U_{p}_{ell[0]}_{ell[1]}_s{s}_d{t}")
349
+
350
+ # Note: IDLE variables removed - we only track employees actually working on production
351
+
352
+ # Variable to track actual number of employees of each type working each shift each day
353
+ # This represents how many distinct employees of type e are working in shift s on day t
354
+ EMPLOYEE_COUNT = {}
355
+ for e in employee_type_list:
356
+ for s in active_shift_list:
357
+ for t in date_span_list:
358
+ # Note: Minimum staffing is per day, not per shift
359
+ # We'll handle the daily minimum constraint separately
360
+ max_count = max_employee_type_day.get(e, {}).get(t, 100)
361
+ EMPLOYEE_COUNT[e, s, t] = solver.IntVar(
362
+ 0, # No minimum per shift (daily minimum handled separately)
363
+ max_count,
364
+ f"EmpCount_{e}_s{s}_day{t}"
365
+ )
366
 
367
+ # Track total person-hours worked by each employee type per shift per day
368
+ # This is needed for employee-centric wage calculation
369
+ EMPLOYEE_HOURS = {}
370
+ for e in employee_type_list:
371
+ for s in active_shift_list:
372
+ for t in date_span_list:
373
+ # Sum of all work hours for employee type e in shift s on day t
374
+ # This represents total person-hours (e.g., 5 employees Γ— 8 hours = 40 person-hours)
375
+ EMPLOYEE_HOURS[e, s, t] = solver.Sum(
376
+ TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t]
377
+ for p in sorted_product_list
378
+ for ell in line_tuples
379
+ )
380
 
381
+ # Note: Binary variables for bulk payment are now created inline in the cost calculation
 
 
 
 
382
 
383
+ # --- Objective: Minimize total labor cost (wages) ---
384
+ # Employee-centric approach: calculate wages based on actual employees and their hours
385
+ print(f"\n[DEBUG] Payment mode configuration: {PAYMENT_MODE_CONFIG}")
386
+
387
+ # Build cost terms based on payment mode
388
+ cost_terms = []
389
+
390
+ for e in employee_type_list:
391
  for s in active_shift_list:
392
  for t in date_span_list:
393
+ payment_mode = PAYMENT_MODE_CONFIG.get(s, "partial") # Default to partial if not specified
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
+ if payment_mode == "partial":
396
+ # Partial payment: pay for actual person-hours worked
397
+ # Cost = hourly_rate Γ— total_person_hours
398
+ # Example: $20/hr Γ— 40 person-hours = $800
399
+ cost_terms.append(cost[e][s] * EMPLOYEE_HOURS[e, s, t])
400
 
401
+ elif payment_mode == "bulk":
402
+ # Bulk payment: if ANY work happens in shift, pay ALL working employees for FULL shift
403
+ # We need to know: did employee type e work at all in shift s on day t?
404
+
405
+ # Create binary: 1 if employee type e worked in this shift
406
+ work_in_shift = solver.BoolVar(f"work_{e}_s{s}_d{t}")
407
+
408
+ # Link binary to work hours
409
+ # If EMPLOYEE_HOURS > 0, then work_in_shift = 1
410
+ # If EMPLOYEE_HOURS = 0, then work_in_shift = 0
411
+ max_possible_hours = Hmax_s[s] * max_employee_type_day[e][t]
412
+ solver.Add(EMPLOYEE_HOURS[e, s, t] <= max_possible_hours * work_in_shift)
413
+ solver.Add(work_in_shift * 0.001 <= EMPLOYEE_HOURS[e, s, t])
414
+
415
+ # Calculate number of employees working in this shift
416
+ # This is approximately: ceil(EMPLOYEE_HOURS / Hmax_s[s])
417
+ # But we can use: employees_working_in_shift
418
+ # For simplicity, use EMPLOYEE_HOURS / Hmax_s[s] as continuous approximation
419
+ # Or better: create a variable for employees per shift
420
+
421
+ # Simpler approach: For bulk payment, assume if work happens,
422
+ # we need approximately EMPLOYEE_HOURS/Hmax_s[s] employees,
423
+ # and each gets paid for full shift
424
+ # Cost β‰ˆ (EMPLOYEE_HOURS / Hmax_s[s]) Γ— Hmax_s[s] Γ— hourly_rate = EMPLOYEE_HOURS Γ— hourly_rate
425
+ # But that's the same as partial! The difference is we round up employees.
426
+
427
+ # Better approach: Create variable for employees working in this specific shift
428
+ employees_in_shift = solver.IntVar(0, max_employee_type_day[e][t], f"emp_{e}_s{s}_d{t}")
429
+
430
+ # Link employees_in_shift to work requirements
431
+ # If EMPLOYEE_HOURS requires N employees, then employees_in_shift >= ceil(N)
432
+ solver.Add(employees_in_shift * Hmax_s[s] >= EMPLOYEE_HOURS[e, s, t])
433
+
434
+ # Cost: pay each employee for full shift
435
+ cost_terms.append(cost[e][s] * Hmax_s[s] * employees_in_shift)
436
+
437
+ # Note: No idle employee costs - only pay for employees actually working
438
 
439
+ total_cost = solver.Sum(cost_terms)
 
440
 
441
+ # Objective: minimize total labor cost (wages)
442
+ # This finds the optimal production schedule (product order, line assignment, timing)
443
+ # that minimizes total wages while meeting all demand and capacity constraints
444
+ solver.Minimize(total_cost)
445
 
446
+ # --- Constraints ---
447
+
448
+ # 1) Weekly demand - must meet exactly (no over/under production)
449
+ for p in sorted_product_list:
450
+ total_production = solver.Sum(Units[p, ell, s, t] for ell in line_tuples for s in active_shift_list for t in date_span_list)
451
+ demand = DEMAND_DICTIONARY.get(p, 0)
452
+
453
+ # Must produce at least the demand
454
+ solver.Add(total_production >= demand)
455
+
456
+ # Must not produce more than the demand (prevent overproduction)
457
+ solver.Add(total_production <= demand)
458
+
459
+ # 2) One product per (line,shift,day) + time gating
460
  for ell in line_tuples:
 
461
  for s in active_shift_list:
462
  for t in date_span_list:
463
+ solver.Add(solver.Sum(Assignment[p, ell, s, t] for p in sorted_product_list) <= 1)
464
+ for p in sorted_product_list:
465
+ solver.Add(Hours[p, ell, s, t] <= Hmax_s[s] * Assignment[p, ell, s, t])
 
466
 
467
+ # 3) Product-line type compatibility + (optional) activity by day
468
+ for p in sorted_product_list:
469
+ req_lt = KIT_LINE_MATCH_DICT.get(p, LineType.LONG_LINE) # Default to long line if not found
470
+ req_total = sum(TEAM_REQ_PER_PRODUCT[e][p] for e in employee_type_list)
471
+ for ell in line_tuples:
472
+ allowed = (ell[0] == req_lt) and (req_total <= max_workers_line.get(ell[0], 1e9))
473
+ for s in active_shift_list:
474
+ for t in date_span_list:
475
+ if ACTIVE[t][p] == 0 or not allowed:
476
+ solver.Add(Assignment[p, ell, s, t] == 0)
477
+ solver.Add(Hours[p, ell, s, t] == 0)
478
+ solver.Add(Units[p, ell, s, t] == 0)
479
+
480
+ # 4) Line throughput: Units ≀ product_speed * Hours
481
+ for p in sorted_product_list:
482
+ for ell in line_tuples:
483
+ for s in active_shift_list:
484
+ for t in date_span_list:
485
+ # Get product speed (same speed regardless of line type)
486
+ if p in PER_PRODUCT_SPEED:
487
+ # Convert kit per day to kit per hour (assuming 7.5 hour workday)
488
+ speed = PER_PRODUCT_SPEED[p]
489
+ # Upper bound: units cannot exceed capacity
490
+ solver.Add(
491
+ Units[p, ell, s, t] <= speed * Hours[p, ell, s, t]
492
+ )
493
+ # Lower bound: if working, must produce (prevent phantom work)
494
+ solver.Add(
495
+ Units[p, ell, s, t] >= speed * Hours[p, ell, s, t]
496
+ )
497
+ else:
498
+ # Default speed if not found
499
+ default_speed = 800 / 7.5 # units per hour
500
+ print(f"Warning: No speed data for product {p}, using default {default_speed:.1f} per hour")
501
+ # Upper bound: units cannot exceed capacity
502
+ solver.Add(
503
+ Units[p, ell, s, t] <= default_speed * Hours[p, ell, s, t]
504
+ )
505
+ # Lower bound: if working, must produce (prevent phantom work)
506
+ solver.Add(
507
+ Units[p, ell, s, t] >= default_speed * Hours[p, ell, s, t]
508
+ )
509
+
510
+ # Working hours constraint: active employees cannot exceed shift hour capacity
511
+ for e in employee_type_list:
512
  for s in active_shift_list:
513
  for t in date_span_list:
514
+ # No idle employee constraints - employees are only counted when working
515
+ solver.Add(
516
+ solver.Sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t] for p in sorted_product_list for ell in line_tuples)
517
+ <= Hmax_s[s] * max_employee_type_day[e][t]
518
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
 
520
+ # 6) Per-shift staffing capacity by type: link employee count to actual work hours
521
+ # This constraint ensures EMPLOYEE_COUNT[e,s,t] represents the actual number of employees needed in each shift
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  for e in employee_type_list:
523
+ for s in active_shift_list:
524
+ for t in date_span_list:
525
+ # Total person-hours worked by employee type e in shift s on day t
526
+ total_person_hours_in_shift = solver.Sum(
527
+ TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t]
528
+ for p in sorted_product_list
529
+ for ell in line_tuples
530
+ )
531
+
532
+ # Employee count must be sufficient to cover the work in this shift
533
+ # If employees work H person-hours total and each can work max M hours/shift,
534
+ # then we need at least ceil(H/M) employees
535
+ # Constraint: employee_count Γ— max_hours_per_shift >= total_person_hours_in_shift
536
+ solver.Add(EMPLOYEE_COUNT[e, s, t] * Hmax_s[s] >= total_person_hours_in_shift)
537
+
538
+ # 7) Shift ordering constraints (only apply if shifts are available)
539
+ # Evening shift after regular shift
540
+ if ShiftType.EVENING in active_shift_list and ShiftType.REGULAR in active_shift_list: # Only if both shifts are available
541
+ for e in employee_type_list:
542
+ for t in date_span_list:
543
+ solver.Add(
544
+ solver.Sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.EVENING, t] for p in sorted_product_list for ell in line_tuples)
545
+ <=
546
+ solver.Sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.REGULAR, t] for p in sorted_product_list for ell in line_tuples)
547
+ )
548
 
549
+ # Overtime should only be used when regular shift is at capacity
550
+ if ShiftType.OVERTIME in active_shift_list and ShiftType.REGULAR in active_shift_list: # Only if both shifts are available
551
+ print("\n[OVERTIME] Adding constraints to ensure overtime only when regular shift is insufficient...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
+ for e in employee_type_list:
554
+ for t in date_span_list:
555
+ # Get available regular capacity for this employee type and day
556
+ regular_capacity = max_employee_type_day[e][t]
557
+
558
+ # Total regular shift usage for this employee type and day
559
+ regular_usage = solver.Sum(
560
+ TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.REGULAR, t]
561
+ for p in sorted_product_list for ell in line_tuples
562
  )
563
+
564
+ # Total overtime usage for this employee type and day
565
+ overtime_usage = solver.Sum(
566
+ TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, ShiftType.OVERTIME, t]
567
+ for p in sorted_product_list for ell in line_tuples
568
  )
569
 
570
+ # Create binary variable: 1 if using overtime, 0 otherwise
571
+ using_overtime = solver.IntVar(0, 1, f'using_overtime_{e}_{t}')
 
572
 
573
+ # If using overtime, regular capacity must be utilized significantly
574
+ # Regular usage must be at least 90% of capacity to allow overtime
575
+ min_regular_for_overtime = int(0.9 * regular_capacity)
576
+
577
+ # Constraint 1: Can only use overtime if regular usage is high
578
+ solver.Add(regular_usage >= min_regular_for_overtime * using_overtime)
579
+
580
+ # Constraint 2: If any overtime is used, set the binary variable
581
+ solver.Add(overtime_usage <= regular_capacity * using_overtime)
582
+
583
+ overtime_constraints_added = len(employee_type_list) * len(date_span_list) * 2 # 2 constraints per employee type per day
584
+ print(f"[OVERTIME] Added {overtime_constraints_added} constraints ensuring overtime only when regular shifts are at 90%+ capacity")
585
+
586
+ # 7.5) Bulk payment linking constraints are now handled inline in the cost calculation
587
+
588
+ # 7.6) *** FIXED MINIMUM UNICEF EMPLOYEES CONSTRAINT ***
589
+ # Ensure minimum UNICEF fixed-term staff work in the REGULAR shift every day
590
+ # The minimum applies to the regular shift specifically (not overtime or evening)
591
+ if 'UNICEF Fixed term' in employee_type_list and FIXED_MIN_UNICEF_PER_DAY > 0:
592
+ if ShiftType.REGULAR in active_shift_list:
593
+ print(f"\n[FIXED STAFFING] Adding constraint for minimum {FIXED_MIN_UNICEF_PER_DAY} UNICEF employees in REGULAR shift per day...")
594
+ for t in date_span_list:
595
+ # At least FIXED_MIN_UNICEF_PER_DAY employees must work in the regular shift each day
596
+ solver.Add(
597
+ EMPLOYEE_COUNT['UNICEF Fixed term', ShiftType.REGULAR, t] >= FIXED_MIN_UNICEF_PER_DAY
598
+ )
599
+ print(f"[FIXED STAFFING] Added {len(date_span_list)} constraints ensuring >= {FIXED_MIN_UNICEF_PER_DAY} UNICEF employees in regular shift per day")
600
+ else:
601
+ print(f"\n[FIXED STAFFING] Warning: Regular shift not available, cannot enforce minimum UNICEF staffing")
602
+
603
+ # 8) *** HIERARCHY DEPENDENCY CONSTRAINTS ***
604
+ # For subkits with prepack dependencies: dependencies should be produced before or same time
605
+ print("\n[HIERARCHY] Adding dependency constraints...")
606
+ dependency_constraints_added = 0
607
+
608
+ for p in sorted_product_list:
609
+ dependencies = KIT_DEPENDENCIES.get(p, [])
610
+ if dependencies:
611
+ # Get the level of the current product
612
+ p_level = KIT_LEVELS.get(p, 2)
613
+
614
+ for dep in dependencies:
615
+ if dep in sorted_product_list: # Only if dependency is also in production list
616
+ # Calculate "completion time" for each product (sum of all production times)
617
+ p_completion = solver.Sum(
618
+ t * Hours[p, ell, s, t] for ell in line_tuples for s in active_shift_list for t in date_span_list
619
+ )
620
+ dep_completion = solver.Sum(
621
+ t * Hours[dep, ell, s, t] for ell in line_tuples for s in active_shift_list for t in date_span_list
622
+ )
623
+
624
+ # Dependency should complete before or at the same time
625
+ solver.Add(dep_completion <= p_completion)
626
+ dependency_constraints_added += 1
627
+
628
+ print(f" Added constraint: {dep} (dependency) <= {p} (level {p_level})")
629
+
630
+ print(f"[HIERARCHY] Added {dependency_constraints_added} dependency constraints")
631
+
632
+ # --- Solve ---
633
+ status = solver.Solve()
634
+ if status != pywraplp.Solver.OPTIMAL:
635
+ status_names = {pywraplp.Solver.INFEASIBLE: "INFEASIBLE", pywraplp.Solver.UNBOUNDED: "UNBOUNDED"}
636
+ print(f"No optimal solution. Status: {status} ({status_names.get(status, 'UNKNOWN')})")
637
+ # Debug hint:
638
+ # solver.EnableOutput()
639
+ # solver.ExportModelAsLpFile("model.lp")
640
+ return None
641
+
642
+ # --- Report ---
643
+ result = {}
644
+ result['objective'] = solver.Objective().Value()
645
+
646
+ # Weekly production
647
+ prod_week = {p: sum(Units[p, ell, s, t].solution_value() for ell in line_tuples for s in active_shift_list for t in date_span_list) for p in sorted_product_list}
648
+ result['weekly_production'] = prod_week
649
+
650
+ # Which product ran on which line/shift/day
651
+ schedule = []
652
+ for t in date_span_list:
653
+ for ell in line_tuples:
654
+ for s in active_shift_list:
655
+ chosen = [p for p in sorted_product_list if Assignment[p, ell, s, t].solution_value() > 0.5]
656
+ if chosen:
657
+ p = chosen[0]
658
+ schedule.append({
659
+ 'day': t,
660
+ 'line_type_id': ell[0],
661
+ 'line_idx': ell[1],
662
+ 'shift': s,
663
+ 'product': p,
664
+ 'run_hours': Hours[p, ell, s, t].solution_value(),
665
+ 'units': Units[p, ell, s, t].solution_value(),
666
+ })
667
+ result['run_schedule'] = schedule
668
+
669
+ # Implied headcount by type/shift/day (ceil)
670
+ headcount = []
671
+ for e in employee_type_list:
672
  for s in active_shift_list:
673
+ for t in date_span_list:
674
+ used_ph = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value() for p in sorted_product_list for ell in line_tuples)
675
+ need = ceil(used_ph / (Hmax_s[s] + 1e-9))
676
+ headcount.append({'emp_type': e, 'shift': s, 'day': t,
677
+ 'needed': need, 'available': max_employee_type_day[e][t]})
678
+ result['headcount_per_shift'] = headcount
679
+
680
+ # Total person-hours by type/day (≀ 14h * headcount)
681
+ ph_by_day = []
682
+ for e in employee_type_list:
 
 
 
 
 
 
 
 
683
  for t in date_span_list:
684
+ used = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value() for s in active_shift_list for p in sorted_product_list for ell in line_tuples)
685
+ ph_by_day.append({'emp_type': e, 'day': t,
686
+ 'used_person_hours': used,
687
+ 'cap_person_hours': Hmax_daily * max_employee_type_day[e][t]})
688
+ result['person_hours_by_day'] = ph_by_day
689
+
690
+ # Actual employee count per type/shift/day (from EMPLOYEE_COUNT variable)
691
+ employee_count_by_shift = []
692
+ for e in employee_type_list:
693
+ for s in active_shift_list:
694
+ for t in date_span_list:
695
+ count = int(EMPLOYEE_COUNT[e, s, t].solution_value())
696
+ used_hours = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value()
697
+ for p in sorted_product_list for ell in line_tuples)
698
+ avg_hours_per_employee = used_hours / count if count > 0 else 0
699
+ if count > 0: # Only add entries where employees are working
700
+ employee_count_by_shift.append({
701
+ 'emp_type': e,
702
+ 'shift': s,
703
+ 'day': t,
704
+ 'employee_count': count,
705
+ 'total_person_hours': used_hours,
706
+ 'avg_hours_per_employee': avg_hours_per_employee,
707
+ 'available': max_employee_type_day[e][t]
708
+ })
709
+ result['employee_count_by_shift'] = employee_count_by_shift
710
+
711
+ # Also calculate daily totals (summing across shifts)
712
+ employee_count_by_day = []
713
+ for e in employee_type_list:
714
  for t in date_span_list:
715
+ # Sum employees across all shifts for this day
716
+ total_count = sum(int(EMPLOYEE_COUNT[e, s, t].solution_value()) for s in active_shift_list)
717
  used_hours = sum(TEAM_REQ_PER_PRODUCT[e][p] * Hours[p, ell, s, t].solution_value()
718
+ for s in active_shift_list for p in sorted_product_list for ell in line_tuples)
719
+ avg_hours_per_employee = used_hours / total_count if total_count > 0 else 0
720
+ if total_count > 0: # Only add days where employees are working
721
+ employee_count_by_day.append({
722
  'emp_type': e,
 
723
  'day': t,
724
+ 'employee_count': total_count,
725
  'total_person_hours': used_hours,
726
  'avg_hours_per_employee': avg_hours_per_employee,
727
  'available': max_employee_type_day[e][t]
728
  })
729
+ result['employee_count_by_day'] = employee_count_by_day
730
+
731
+ # Note: Idle employee tracking removed - only counting employees actually working
732
+
733
+ # Pretty print
734
+ print("Objective (min cost):", result['objective'])
735
+ print("\n--- Weekly production by product ---")
736
+ for p, u in prod_week.items():
737
+ print(f"{p}: {u:.1f} / demand {DEMAND_DICTIONARY.get(p,0)}")
738
+
739
+ print("\n--- Schedule (line, shift, day) ---")
740
+ for row in schedule:
741
+ shift_name = ShiftType.get_name(row['shift'])
742
+ line_name = LineType.get_name(row['line_type_id'])
743
+ print(f"date_span_list{row['day']} {line_name}-{row['line_idx']} {shift_name}: "
744
+ f"{row['product']} Hours={row['run_hours']:.2f}h Units={row['units']:.1f}")
745
+
746
+ print("\n--- Implied headcount need (per type/shift/day) ---")
747
+ for row in headcount:
748
+ shift_name = ShiftType.get_name(row['shift'])
749
+ print(f"{row['emp_type']}, {shift_name}, date_span_list{row['day']}: "
750
+ f"need={row['needed']} (avail {row['available']})")
751
+
752
+ print("\n--- Total person-hours by type/day ---")
753
+ for row in ph_by_day:
754
+ print(f"{row['emp_type']}, date_span_list{row['day']}: used={row['used_person_hours']:.1f} "
755
+ f"(cap {row['cap_person_hours']})")
756
+
757
+ print("\n--- Actual employee count by type/shift/day ---")
758
+ for row in employee_count_by_shift:
759
+ shift_name = ShiftType.get_name(row['shift'])
760
+ print(f"{row['emp_type']}, {shift_name}, date_span_list{row['day']}: "
761
+ f"count={row['employee_count']} employees, "
762
+ f"total_hours={row['total_person_hours']:.1f}h, "
763
+ f"avg={row['avg_hours_per_employee']:.1f}h/employee")
764
+
765
+ print("\n--- Daily employee totals by type/day (sum across shifts) ---")
766
+ for row in employee_count_by_day:
767
+ print(f"{row['emp_type']}, date_span_list{row['day']}: "
768
+ f"count={row['employee_count']} employees total, "
769
+ f"total_hours={row['total_person_hours']:.1f}h, "
770
+ f"avg={row['avg_hours_per_employee']:.1f}h/employee "
771
+ f"(available: {row['available']})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
 
773
+ # Note: Idle employee reporting removed - only tracking employees actually working
774
 
775
+ return result
776
 
777
 
778
  if __name__ == "__main__":
779
+ optimizer = Optimizer()
780
+ optimizer.run_optimization()
src/preprocess/hierarchy_parser.py CHANGED
@@ -107,6 +107,99 @@ class KitHierarchyParser:
107
 
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def main():
111
  """Demo the hierarchy parser"""
112
  parser = KitHierarchyParser()
 
107
 
108
 
109
 
110
+ def sort_products_by_hierarchy(product_list: List[str],
111
+ kit_levels: Dict[str, int],
112
+ kit_dependencies: Dict[str, List[str]]) -> List[str]:
113
+ """
114
+ Sort products by hierarchy levels and dependencies using topological sorting.
115
+ Returns products in optimal production order: prepacks β†’ subkits β†’ masters
116
+ Dependencies within the same level are properly ordered.
117
+
118
+ Args:
119
+ product_list: List of product names to sort
120
+ kit_levels: Dictionary mapping product names to hierarchy levels (0=prepack, 1=subkit, 2=master)
121
+ kit_dependencies: Dictionary mapping product names to their dependencies (products that must be made first)
122
+
123
+ Returns:
124
+ List of products sorted in production order (dependencies first)
125
+ """
126
+ # Filter products that are in our production list and have hierarchy data
127
+ products_with_hierarchy = [p for p in product_list if p in kit_levels]
128
+ products_without_hierarchy = [p for p in product_list if p not in kit_levels]
129
+
130
+ if products_without_hierarchy:
131
+ print(f"[HIERARCHY] Products without hierarchy data: {products_without_hierarchy}")
132
+
133
+ # Build dependency graph for products in our list
134
+ graph = defaultdict(list) # product -> [dependents]
135
+ in_degree = defaultdict(int) # product -> number of dependencies
136
+
137
+ # Initialize all products
138
+ for product in products_with_hierarchy:
139
+ in_degree[product] = 0
140
+
141
+ for product in products_with_hierarchy:
142
+ deps = kit_dependencies.get(product, []) # dependencies = products that has to be packed first
143
+ for dep in deps:
144
+ if dep in products_with_hierarchy: # Only if dependency is in our production list
145
+ # REVERSE THE RELATIONSHIP:
146
+ # kit_dependencies says: "product needs dep"
147
+ # graph says: "dep is needed by product"
148
+ graph[dep].append(product) # dep -> product (reverse the relationship!)
149
+ in_degree[product] += 1
150
+
151
+ # Topological sort with hierarchy level priority
152
+ sorted_products = []
153
+ # queue = able to remove from both sides
154
+ queue = deque()
155
+
156
+ # Start with products that have no dependencies
157
+ for product in products_with_hierarchy:
158
+ if in_degree[product] == 0:
159
+ queue.append(product)
160
+
161
+ while queue:
162
+ current = queue.popleft()
163
+ sorted_products.append(current)
164
+
165
+ # Process dependents - sort by hierarchy level first
166
+ for dependent in sorted(graph[current], key=lambda p: (kit_levels.get(p, 999), p)):
167
+ in_degree[dependent] -= 1 # decrement the in_degree of the dependent
168
+ if in_degree[dependent] == 0: # if the in_degree of the dependent is 0, add it to the queue so that it can be processed
169
+ queue.append(dependent)
170
+
171
+ # Check for cycles (shouldn't happen with proper hierarchy)
172
+ if len(sorted_products) != len(products_with_hierarchy):
173
+ remaining = [p for p in products_with_hierarchy if p not in sorted_products]
174
+ print(f"[HIERARCHY] WARNING: Potential circular dependencies detected in: {remaining}")
175
+ # Add remaining products sorted by level as fallback
176
+ remaining_sorted = sorted(remaining, key=lambda p: (kit_levels.get(p, 999), p))
177
+ sorted_products.extend(remaining_sorted)
178
+
179
+ # Add products without hierarchy information at the end
180
+ sorted_products.extend(sorted(products_without_hierarchy))
181
+
182
+ print(f"[HIERARCHY] Dependency-aware production order: {len(sorted_products)} products")
183
+ for i, p in enumerate(sorted_products[:10]): # Show first 10
184
+ level = kit_levels.get(p, "unknown")
185
+ # Import here to avoid circular dependency
186
+ try:
187
+ from src.config.constants import KitLevel
188
+ level_name = KitLevel.get_name(level)
189
+ except:
190
+ level_name = f"level_{level}"
191
+ deps = kit_dependencies.get(p, [])
192
+ deps_in_list = [d for d in deps if d in products_with_hierarchy]
193
+ print(f" {i+1}. {p} (level {level}={level_name}, deps: {len(deps_in_list)})")
194
+ if deps_in_list:
195
+ print(f" Dependencies: {deps_in_list}")
196
+
197
+ if len(sorted_products) > 10:
198
+ print(f" ... and {len(sorted_products) - 10} more products")
199
+
200
+ return sorted_products
201
+
202
+
203
  def main():
204
  """Demo the hierarchy parser"""
205
  parser = KitHierarchyParser()
ui/pages/config_page.py CHANGED
@@ -8,7 +8,24 @@ import datetime
8
  import sys
9
  import os
10
  from src.config import optimization_config
11
- from src.config.constants import ShiftType, LineType
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  def render_config_page():
14
  """Render the configuration page with all user input controls"""
@@ -48,20 +65,8 @@ def render_config_page():
48
  config = save_configuration()
49
  st.success("βœ… Settings saved successfully!")
50
 
51
- # Clear any previous optimization results since settings changed
52
- if 'optimization_results' in st.session_state:
53
- del st.session_state.optimization_results
54
-
55
- # Clear any previous validation state
56
- if 'show_validation_after_save' in st.session_state:
57
- del st.session_state.show_validation_after_save
58
- if 'settings_just_saved' in st.session_state:
59
- del st.session_state.settings_just_saved
60
-
61
- # Clear any cached validation results since settings changed
62
- validation_cache_key = f"validation_results_{st.session_state.get('start_date', 'default')}"
63
- if validation_cache_key in st.session_state:
64
- del st.session_state[validation_cache_key]
65
 
66
  # Trigger fresh demand validation after saving settings
67
  st.session_state.show_validation_after_save = True
@@ -245,56 +250,56 @@ def render_config_page():
245
  display_optimization_results(st.session_state.optimization_results)
246
 
247
  def initialize_session_state():
248
- """Initialize session state with simple default values using Streamlit's standard pattern"""
249
 
250
- # Simple default values - no complex imports or function calls
251
  # Use setdefault to avoid overwriting existing values
252
 
253
  # Schedule defaults
254
  st.session_state.setdefault('start_date', datetime.date(2025, 7, 7))
255
- st.session_state.setdefault('schedule_type', 'weekly')
256
 
257
  # Shift defaults
258
- st.session_state.setdefault('evening_shift_mode', 'normal')
259
- st.session_state.setdefault('evening_shift_threshold', 0.9)
260
 
261
  # Staff defaults
262
- st.session_state.setdefault('fixed_staff_mode', 'priority')
263
- st.session_state.setdefault('fixed_min_unicef_per_day', 5)
264
 
265
  # Payment modes
266
- st.session_state.setdefault('payment_mode_shift_1', 'bulk')
267
- st.session_state.setdefault('payment_mode_shift_2', 'bulk')
268
- st.session_state.setdefault('payment_mode_shift_3', 'partial')
269
 
270
  # Working hours
271
- st.session_state.setdefault('max_hour_per_person_per_day', 14)
272
- st.session_state.setdefault('max_hours_shift_1', 9.0)
273
- st.session_state.setdefault('max_hours_shift_2', 7.0)
274
- st.session_state.setdefault('max_hours_shift_3', 4.0)
275
 
276
  # Workforce limits
277
- st.session_state.setdefault('max_unicef_per_day', 8)
278
- st.session_state.setdefault('max_humanizer_per_day', 10)
279
 
280
  # Operations
281
- st.session_state.setdefault('line_count_long_line', 3)
282
- st.session_state.setdefault('line_count_mini_load', 2)
283
- st.session_state.setdefault('max_parallel_workers_long_line', 7)
284
- st.session_state.setdefault('max_parallel_workers_mini_load', 5)
285
 
286
  # Cost rates
287
- st.session_state.setdefault('unicef_rate_shift_1', 12.5)
288
- st.session_state.setdefault('unicef_rate_shift_2', 15.0)
289
- st.session_state.setdefault('unicef_rate_shift_3', 18.75)
290
- st.session_state.setdefault('humanizer_rate_shift_1', 10.0)
291
- st.session_state.setdefault('humanizer_rate_shift_2', 12.0)
292
- st.session_state.setdefault('humanizer_rate_shift_3', 15.0)
293
 
294
  # Data selection
295
  st.session_state.setdefault('selected_employee_types', ["UNICEF Fixed term", "Humanizer"])
296
- st.session_state.setdefault('selected_shifts', [1, 3])
297
- st.session_state.setdefault('selected_lines', [6, 7])
298
 
299
  def render_schedule_config():
300
  """Render schedule configuration section"""
@@ -303,9 +308,10 @@ def render_schedule_config():
303
  col1, col2 = st.columns(2)
304
 
305
  with col1:
306
- st.session_state.start_date = st.date_input(
307
  "Start Date",
308
- value=st.session_state.get('start_date', datetime.date(2025, 7, 7)),
 
309
  help="Exact start date to filter demand data - will only use orders that start on this specific date"
310
  )
311
 
@@ -316,12 +322,13 @@ def render_schedule_config():
316
  # Evening shift configuration
317
  st.subheader("πŸŒ™ Evening Shift Configuration")
318
 
319
- st.session_state.evening_shift_mode = st.selectbox(
 
 
320
  "Evening Shift Mode",
321
- options=['normal', 'activate_evening', 'always_available'],
322
- index=['normal', 'activate_evening', 'always_available'].index(
323
- st.session_state.get('evening_shift_mode', 'normal')
324
- ),
325
  help="""
326
  - **Normal**: Only regular shift (1) and overtime shift (3)
327
  - **Activate Evening**: Allow evening shift (2) when demand is high
@@ -329,12 +336,13 @@ def render_schedule_config():
329
  """
330
  )
331
 
332
- if st.session_state.get('evening_shift_mode') == 'activate_evening':
333
- st.session_state.evening_shift_threshold = st.slider(
334
  "Evening Shift Activation Threshold",
335
  min_value=0.1,
336
  max_value=1.0,
337
- value=st.session_state.get('evening_shift_threshold', 0.9),
 
338
  step=0.1,
339
  help="Activate evening shift if regular+overtime capacity < threshold of demand"
340
  )
@@ -344,12 +352,13 @@ def render_workforce_config():
344
  st.header("πŸ‘₯ Workforce Configuration")
345
 
346
  # Fixed staff constraint mode
347
- st.session_state.fixed_staff_mode = st.selectbox(
 
 
348
  "Fixed Staff Constraint Mode",
349
- options=['mandatory', 'available', 'priority', 'none'],
350
- index=['mandatory', 'available', 'priority', 'none'].index(
351
- st.session_state.get('fixed_staff_mode', 'priority')
352
- ),
353
  help="""
354
  - **Mandatory**: Forces all fixed staff to work full hours every day
355
  - **Available**: Staff available up to limits but not forced
@@ -364,73 +373,80 @@ def render_workforce_config():
364
  col1, col2 = st.columns(2)
365
 
366
  with col1:
367
- st.session_state.max_unicef_per_day = st.number_input(
368
  "Max UNICEF Fixed Term per Day",
369
  min_value=1,
370
  max_value=50,
371
- value=st.session_state.get('max_unicef_per_day', 8),
 
372
  help="Maximum number of UNICEF fixed term employees per day"
373
  )
374
 
375
  with col2:
376
- st.session_state.max_humanizer_per_day = st.number_input(
377
  "Max Humanizer per Day",
378
  min_value=1,
379
  max_value=50,
380
- value=st.session_state.get('max_humanizer_per_day', 10),
 
381
  help="Maximum number of Humanizer employees per day"
382
  )
383
 
384
  # Fixed minimum UNICEF requirement
385
  st.subheader("πŸ”’ Fixed Minimum Requirements")
386
 
387
- st.session_state.fixed_min_unicef_per_day = st.number_input(
388
  "Fixed Minimum UNICEF per Day",
389
  min_value=0,
390
  max_value=20,
391
- value=st.session_state.get('fixed_min_unicef_per_day', 5),
 
392
  help="Minimum number of UNICEF Fixed term employees required every working day (constraint)"
393
  )
394
 
395
  # Working hours configuration
396
  st.subheader("⏰ Working Hours Configuration")
397
 
398
- st.session_state.max_hour_per_person_per_day = st.number_input(
399
  "Max Hours per Person per Day",
400
  min_value=1,
401
  max_value=24,
402
- value=st.session_state.get('max_hour_per_person_per_day', 14),
 
403
  help="Legal maximum working hours per person per day"
404
  )
405
 
406
  col1, col2, col3 = st.columns(3)
407
 
408
  with col1:
409
- st.session_state.max_hours_shift_1 = st.number_input(
410
  "Max Hours - Shift 1 (Regular)",
411
  min_value=1.0,
412
  max_value=12.0,
413
- value=float(st.session_state.get('max_hours_shift_1', 9.0)),
 
414
  step=0.5,
415
  help="Maximum hours per person for regular shift"
416
  )
417
 
418
  with col2:
419
- st.session_state.max_hours_shift_2 = st.number_input(
420
  "Max Hours - Shift 2 (Evening)",
421
  min_value=1.0,
422
  max_value=12.0,
423
- value=float(st.session_state.get('max_hours_shift_2', 7.0)),
 
424
  step=0.5,
425
  help="Maximum hours per person for evening shift"
426
  )
427
 
428
  with col3:
429
- st.session_state.max_hours_shift_3 = st.number_input(
430
  "Max Hours - Shift 3 (Overtime)",
431
  min_value=1.0,
432
  max_value=12.0,
433
- value=float(st.session_state.get('max_hours_shift_3', 4.0)),
 
434
  step=0.5,
435
  help="Maximum hours per person for overtime shift"
436
  )
@@ -445,36 +461,40 @@ def render_operations_config():
445
  col1, col2 = st.columns(2)
446
 
447
  with col1:
448
- st.session_state.line_count_long_line = st.number_input(
449
  "Number of Long Lines",
450
  min_value=1,
451
  max_value=20,
452
- value=st.session_state.get('line_count_long_line', 3),
 
453
  help="Number of long line production lines available"
454
  )
455
 
456
- st.session_state.max_parallel_workers_long_line = st.number_input(
457
  "Max Workers per Long Line",
458
  min_value=1,
459
  max_value=50,
460
- value=st.session_state.get('max_parallel_workers_long_line', 7),
 
461
  help="Maximum number of workers that can work simultaneously on a long line"
462
  )
463
 
464
  with col2:
465
- st.session_state.line_count_mini_load = st.number_input(
466
  "Number of Mini Load Lines",
467
  min_value=1,
468
  max_value=20,
469
- value=st.session_state.get('line_count_mini_load', 2),
 
470
  help="Number of mini load production lines available"
471
  )
472
 
473
- st.session_state.max_parallel_workers_mini_load = st.number_input(
474
  "Max Workers per Mini Load Line",
475
  min_value=1,
476
  max_value=50,
477
- value=st.session_state.get('max_parallel_workers_mini_load', 5),
 
478
  help="Maximum number of workers that can work simultaneously on a mini load line"
479
  )
480
 
@@ -493,27 +513,32 @@ def render_cost_config():
493
 
494
  col1, col2, col3 = st.columns(3)
495
 
 
 
496
  with col1:
497
- st.session_state.payment_mode_shift_1 = st.selectbox(
498
  "Shift 1 (Regular) Payment",
499
- options=['bulk', 'partial'],
500
- index=0 if st.session_state.get('payment_mode_shift_1', 'bulk') == 'bulk' else 1,
 
501
  help="Payment mode for regular shift"
502
  )
503
 
504
  with col2:
505
- st.session_state.payment_mode_shift_2 = st.selectbox(
506
  "Shift 2 (Evening) Payment",
507
- options=['bulk', 'partial'],
508
- index=0 if st.session_state.get('payment_mode_shift_2', 'bulk') == 'bulk' else 1,
 
509
  help="Payment mode for evening shift"
510
  )
511
 
512
  with col3:
513
- st.session_state.payment_mode_shift_3 = st.selectbox(
514
  "Shift 3 (Overtime) Payment",
515
- options=['bulk', 'partial'],
516
- index=0 if st.session_state.get('payment_mode_shift_3', 'partial') == 'bulk' else 1,
 
517
  help="Payment mode for overtime shift"
518
  )
519
 
@@ -524,33 +549,36 @@ def render_cost_config():
524
  col1, col2, col3 = st.columns(3)
525
 
526
  with col1:
527
- st.session_state.unicef_rate_shift_1 = st.number_input(
528
  "Shift 1 (Regular) - UNICEF",
529
  min_value=0.0,
530
  max_value=200.0,
531
- value=float(st.session_state.get('unicef_rate_shift_1', 12.5)),
 
532
  step=0.01,
533
  format="%.2f",
534
  help="Hourly rate for UNICEF Fixed Term staff during regular shift"
535
  )
536
 
537
  with col2:
538
- st.session_state.unicef_rate_shift_2 = st.number_input(
539
  "Shift 2 (Evening) - UNICEF",
540
  min_value=0.0,
541
  max_value=200.0,
542
- value=float(st.session_state.get('unicef_rate_shift_2', 15.0)),
 
543
  step=0.01,
544
  format="%.2f",
545
  help="Hourly rate for UNICEF Fixed Term staff during evening shift"
546
  )
547
 
548
  with col3:
549
- st.session_state.unicef_rate_shift_3 = st.number_input(
550
  "Shift 3 (Overtime) - UNICEF",
551
  min_value=0.0,
552
  max_value=200.0,
553
- value=float(st.session_state.get('unicef_rate_shift_3', 18.75)),
 
554
  step=0.01,
555
  format="%.2f",
556
  help="Hourly rate for UNICEF Fixed Term staff during overtime shift"
@@ -560,33 +588,36 @@ def render_cost_config():
560
  col1, col2, col3 = st.columns(3)
561
 
562
  with col1:
563
- st.session_state.humanizer_rate_shift_1 = st.number_input(
564
  "Shift 1 (Regular) - Humanizer",
565
  min_value=0.0,
566
  max_value=200.0,
567
- value=float(st.session_state.get('humanizer_rate_shift_1', 10.0)),
 
568
  step=0.01,
569
  format="%.2f",
570
  help="Hourly rate for Humanizer staff during regular shift"
571
  )
572
 
573
  with col2:
574
- st.session_state.humanizer_rate_shift_2 = st.number_input(
575
  "Shift 2 (Evening) - Humanizer",
576
  min_value=0.0,
577
  max_value=200.0,
578
- value=float(st.session_state.get('humanizer_rate_shift_2', 12.0)),
 
579
  step=0.01,
580
  format="%.2f",
581
  help="Hourly rate for Humanizer staff during evening shift"
582
  )
583
 
584
  with col3:
585
- st.session_state.humanizer_rate_shift_3 = st.number_input(
586
  "Shift 3 (Overtime) - Humanizer",
587
  min_value=0.0,
588
  max_value=200.0,
589
- value=float(st.session_state.get('humanizer_rate_shift_3', 15.0)),
 
590
  step=0.01,
591
  format="%.2f",
592
  help="Hourly rate for Humanizer staff during overtime shift"
@@ -602,13 +633,10 @@ def render_data_selection_config():
602
  st.subheader("πŸ‘₯ Employee Types")
603
  available_employee_types = ["UNICEF Fixed term", "Humanizer"]
604
 
605
- if 'selected_employee_types' not in st.session_state:
606
- st.session_state.selected_employee_types = available_employee_types
607
-
608
  selected_employee_types = st.multiselect(
609
  "Select Employee Types to Include",
610
  available_employee_types,
611
- default=st.session_state.get('selected_employee_types', available_employee_types),
612
  help="Choose which employee types to include in the optimization"
613
  )
614
  st.session_state.selected_employee_types = selected_employee_types
@@ -618,13 +646,10 @@ def render_data_selection_config():
618
  available_shifts = list(optimization_config.shift_code_to_name().keys())
619
  shift_names = optimization_config.shift_code_to_name()
620
 
621
- if 'selected_shifts' not in st.session_state:
622
- st.session_state.selected_shifts = available_shifts
623
-
624
  selected_shifts = st.multiselect(
625
  "Select Shifts to Include",
626
  available_shifts,
627
- default=st.session_state.get('selected_shifts', available_shifts),
628
  format_func=lambda x: f"Shift {x} ({shift_names[x]})",
629
  help="Choose which shifts to include in the optimization"
630
  )
@@ -635,13 +660,10 @@ def render_data_selection_config():
635
  available_lines = list(optimization_config.line_code_to_name().keys())
636
  line_names = optimization_config.line_code_to_name()
637
 
638
- if 'selected_lines' not in st.session_state:
639
- st.session_state.selected_lines = available_lines
640
-
641
  selected_lines = st.multiselect(
642
  "Select Production Lines to Include",
643
  available_lines,
644
- default=st.session_state.get('selected_lines', available_lines),
645
  format_func=lambda x: f"Line {x} ({line_names[x]})",
646
  help="Choose which production lines to include in the optimization"
647
  )
@@ -982,9 +1004,11 @@ def clear_all_cache_and_results():
982
 
983
  st.info("🧹 Clearing all cached data and previous results...")
984
 
985
- # 1. Clear all optimization-related session state
 
 
 
986
  keys_to_clear = [
987
- 'optimization_results',
988
  'demand_dictionary',
989
  'kit_hierarchy_data',
990
  'team_requirements'
@@ -1064,11 +1088,12 @@ def run_optimization():
1064
  st.info(f"πŸ‘₯ Max UNICEF/day: {st.session_state.max_unicef_per_day}, Max Humanizer/day: {st.session_state.max_humanizer_per_day}")
1065
 
1066
  # Import and run the optimization (after clearing)
1067
- from src.models.optimizer_real import run_optimization_for_week
1068
 
1069
- # Run the optimization
1070
  with st.spinner('Optimizing workforce schedule...'):
1071
- results = run_optimization_for_week()
 
1072
 
1073
  if results is None:
1074
  st.error("❌ Optimization failed! The problem may be infeasible with current settings.")
@@ -1083,8 +1108,20 @@ def run_optimization():
1083
  st.rerun() # Refresh to show results
1084
 
1085
  except Exception as e:
 
1086
  st.error(f"❌ Error during optimization: {str(e)}")
1087
  st.error("Please check your settings and data files.")
 
 
 
 
 
 
 
 
 
 
 
1088
 
1089
  def check_critical_data_issues():
1090
  """
 
8
  import sys
9
  import os
10
  from src.config import optimization_config
11
+ from src.config.constants import ShiftType, LineType, DefaultConfig
12
+
13
+ def clear_optimization_cache():
14
+ """Clear all cached optimization results and validation data"""
15
+ # Clear any previous optimization results since settings changed
16
+ if 'optimization_results' in st.session_state:
17
+ del st.session_state.optimization_results
18
+
19
+ # Clear any previous validation state
20
+ if 'show_validation_after_save' in st.session_state:
21
+ del st.session_state.show_validation_after_save
22
+ if 'settings_just_saved' in st.session_state:
23
+ del st.session_state.settings_just_saved
24
+
25
+ # Clear any cached validation results since settings changed
26
+ validation_cache_key = f"validation_results_{st.session_state.get('start_date', 'default')}"
27
+ if validation_cache_key in st.session_state:
28
+ del st.session_state[validation_cache_key]
29
 
30
  def render_config_page():
31
  """Render the configuration page with all user input controls"""
 
65
  config = save_configuration()
66
  st.success("βœ… Settings saved successfully!")
67
 
68
+ # Clear all cached optimization and validation data
69
+ clear_optimization_cache()
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  # Trigger fresh demand validation after saving settings
72
  st.session_state.show_validation_after_save = True
 
250
  display_optimization_results(st.session_state.optimization_results)
251
 
252
  def initialize_session_state():
253
+ """Initialize session state with default values from constants.py"""
254
 
255
+ # Use constants from DefaultConfig - no more hardcoded values
256
  # Use setdefault to avoid overwriting existing values
257
 
258
  # Schedule defaults
259
  st.session_state.setdefault('start_date', datetime.date(2025, 7, 7))
260
+ st.session_state.setdefault('schedule_type', DefaultConfig.SCHEDULE_TYPE)
261
 
262
  # Shift defaults
263
+ st.session_state.setdefault('evening_shift_mode', DefaultConfig.EVENING_SHIFT_MODE)
264
+ st.session_state.setdefault('evening_shift_threshold', DefaultConfig.EVENING_SHIFT_DEMAND_THRESHOLD)
265
 
266
  # Staff defaults
267
+ st.session_state.setdefault('fixed_staff_mode', DefaultConfig.FIXED_STAFF_MODE)
268
+ st.session_state.setdefault('fixed_min_unicef_per_day', DefaultConfig.FIXED_MIN_UNICEF_PER_DAY)
269
 
270
  # Payment modes
271
+ st.session_state.setdefault('payment_mode_shift_1', DefaultConfig.PAYMENT_MODE_CONFIG[ShiftType.REGULAR])
272
+ st.session_state.setdefault('payment_mode_shift_2', DefaultConfig.PAYMENT_MODE_CONFIG[ShiftType.EVENING])
273
+ st.session_state.setdefault('payment_mode_shift_3', DefaultConfig.PAYMENT_MODE_CONFIG[ShiftType.OVERTIME])
274
 
275
  # Working hours
276
+ st.session_state.setdefault('max_hour_per_person_per_day', DefaultConfig.MAX_HOUR_PER_PERSON_PER_DAY)
277
+ st.session_state.setdefault('max_hours_shift_1', DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.REGULAR])
278
+ st.session_state.setdefault('max_hours_shift_2', DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.EVENING])
279
+ st.session_state.setdefault('max_hours_shift_3', DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.OVERTIME])
280
 
281
  # Workforce limits
282
+ st.session_state.setdefault('max_unicef_per_day', DefaultConfig.MAX_UNICEF_PER_DAY)
283
+ st.session_state.setdefault('max_humanizer_per_day', DefaultConfig.MAX_HUMANIZER_PER_DAY)
284
 
285
  # Operations
286
+ st.session_state.setdefault('line_count_long_line', DefaultConfig.LINE_COUNT_LONG_LINE)
287
+ st.session_state.setdefault('line_count_mini_load', DefaultConfig.LINE_COUNT_MINI_LOAD)
288
+ st.session_state.setdefault('max_parallel_workers_long_line', DefaultConfig.MAX_PARALLEL_WORKERS_LONG_LINE)
289
+ st.session_state.setdefault('max_parallel_workers_mini_load', DefaultConfig.MAX_PARALLEL_WORKERS_MINI_LOAD)
290
 
291
  # Cost rates
292
+ st.session_state.setdefault('unicef_rate_shift_1', DefaultConfig.UNICEF_RATE_SHIFT_1)
293
+ st.session_state.setdefault('unicef_rate_shift_2', DefaultConfig.UNICEF_RATE_SHIFT_2)
294
+ st.session_state.setdefault('unicef_rate_shift_3', DefaultConfig.UNICEF_RATE_SHIFT_3)
295
+ st.session_state.setdefault('humanizer_rate_shift_1', DefaultConfig.HUMANIZER_RATE_SHIFT_1)
296
+ st.session_state.setdefault('humanizer_rate_shift_2', DefaultConfig.HUMANIZER_RATE_SHIFT_2)
297
+ st.session_state.setdefault('humanizer_rate_shift_3', DefaultConfig.HUMANIZER_RATE_SHIFT_3)
298
 
299
  # Data selection
300
  st.session_state.setdefault('selected_employee_types', ["UNICEF Fixed term", "Humanizer"])
301
+ st.session_state.setdefault('selected_shifts', [ShiftType.REGULAR, ShiftType.OVERTIME])
302
+ st.session_state.setdefault('selected_lines', [LineType.LONG_LINE, LineType.MINI_LOAD])
303
 
304
  def render_schedule_config():
305
  """Render schedule configuration section"""
 
308
  col1, col2 = st.columns(2)
309
 
310
  with col1:
311
+ st.date_input(
312
  "Start Date",
313
+ value=datetime.date(2025, 7, 7),
314
+ key='start_date',
315
  help="Exact start date to filter demand data - will only use orders that start on this specific date"
316
  )
317
 
 
322
  # Evening shift configuration
323
  st.subheader("πŸŒ™ Evening Shift Configuration")
324
 
325
+ shift_mode_options = ['normal', 'activate_evening', 'always_available']
326
+
327
+ st.selectbox(
328
  "Evening Shift Mode",
329
+ options=shift_mode_options,
330
+ index=shift_mode_options.index(DefaultConfig.EVENING_SHIFT_MODE),
331
+ key='evening_shift_mode',
 
332
  help="""
333
  - **Normal**: Only regular shift (1) and overtime shift (3)
334
  - **Activate Evening**: Allow evening shift (2) when demand is high
 
336
  """
337
  )
338
 
339
+ if st.session_state.evening_shift_mode == 'activate_evening':
340
+ st.slider(
341
  "Evening Shift Activation Threshold",
342
  min_value=0.1,
343
  max_value=1.0,
344
+ value=DefaultConfig.EVENING_SHIFT_DEMAND_THRESHOLD,
345
+ key='evening_shift_threshold',
346
  step=0.1,
347
  help="Activate evening shift if regular+overtime capacity < threshold of demand"
348
  )
 
352
  st.header("πŸ‘₯ Workforce Configuration")
353
 
354
  # Fixed staff constraint mode
355
+ staff_mode_options = ['mandatory', 'available', 'priority', 'none']
356
+
357
+ st.selectbox(
358
  "Fixed Staff Constraint Mode",
359
+ options=staff_mode_options,
360
+ index=staff_mode_options.index(DefaultConfig.FIXED_STAFF_MODE),
361
+ key='fixed_staff_mode',
 
362
  help="""
363
  - **Mandatory**: Forces all fixed staff to work full hours every day
364
  - **Available**: Staff available up to limits but not forced
 
373
  col1, col2 = st.columns(2)
374
 
375
  with col1:
376
+ st.number_input(
377
  "Max UNICEF Fixed Term per Day",
378
  min_value=1,
379
  max_value=50,
380
+ value=DefaultConfig.MAX_UNICEF_PER_DAY,
381
+ key='max_unicef_per_day',
382
  help="Maximum number of UNICEF fixed term employees per day"
383
  )
384
 
385
  with col2:
386
+ st.number_input(
387
  "Max Humanizer per Day",
388
  min_value=1,
389
  max_value=50,
390
+ value=DefaultConfig.MAX_HUMANIZER_PER_DAY,
391
+ key='max_humanizer_per_day',
392
  help="Maximum number of Humanizer employees per day"
393
  )
394
 
395
  # Fixed minimum UNICEF requirement
396
  st.subheader("πŸ”’ Fixed Minimum Requirements")
397
 
398
+ st.number_input(
399
  "Fixed Minimum UNICEF per Day",
400
  min_value=0,
401
  max_value=20,
402
+ value=DefaultConfig.FIXED_MIN_UNICEF_PER_DAY,
403
+ key='fixed_min_unicef_per_day',
404
  help="Minimum number of UNICEF Fixed term employees required every working day (constraint)"
405
  )
406
 
407
  # Working hours configuration
408
  st.subheader("⏰ Working Hours Configuration")
409
 
410
+ st.number_input(
411
  "Max Hours per Person per Day",
412
  min_value=1,
413
  max_value=24,
414
+ value=DefaultConfig.MAX_HOUR_PER_PERSON_PER_DAY,
415
+ key='max_hour_per_person_per_day',
416
  help="Legal maximum working hours per person per day"
417
  )
418
 
419
  col1, col2, col3 = st.columns(3)
420
 
421
  with col1:
422
+ st.number_input(
423
  "Max Hours - Shift 1 (Regular)",
424
  min_value=1.0,
425
  max_value=12.0,
426
+ value=float(DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.REGULAR]),
427
+ key='max_hours_shift_1',
428
  step=0.5,
429
  help="Maximum hours per person for regular shift"
430
  )
431
 
432
  with col2:
433
+ st.number_input(
434
  "Max Hours - Shift 2 (Evening)",
435
  min_value=1.0,
436
  max_value=12.0,
437
+ value=float(DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.EVENING]),
438
+ key='max_hours_shift_2',
439
  step=0.5,
440
  help="Maximum hours per person for evening shift"
441
  )
442
 
443
  with col3:
444
+ st.number_input(
445
  "Max Hours - Shift 3 (Overtime)",
446
  min_value=1.0,
447
  max_value=12.0,
448
+ value=float(DefaultConfig.MAX_HOUR_PER_SHIFT_PER_PERSON[ShiftType.OVERTIME]),
449
+ key='max_hours_shift_3',
450
  step=0.5,
451
  help="Maximum hours per person for overtime shift"
452
  )
 
461
  col1, col2 = st.columns(2)
462
 
463
  with col1:
464
+ st.number_input(
465
  "Number of Long Lines",
466
  min_value=1,
467
  max_value=20,
468
+ value=DefaultConfig.LINE_COUNT_LONG_LINE,
469
+ key='line_count_long_line',
470
  help="Number of long line production lines available"
471
  )
472
 
473
+ st.number_input(
474
  "Max Workers per Long Line",
475
  min_value=1,
476
  max_value=50,
477
+ value=DefaultConfig.MAX_PARALLEL_WORKERS_LONG_LINE,
478
+ key='max_parallel_workers_long_line',
479
  help="Maximum number of workers that can work simultaneously on a long line"
480
  )
481
 
482
  with col2:
483
+ st.number_input(
484
  "Number of Mini Load Lines",
485
  min_value=1,
486
  max_value=20,
487
+ value=DefaultConfig.LINE_COUNT_MINI_LOAD,
488
+ key='line_count_mini_load',
489
  help="Number of mini load production lines available"
490
  )
491
 
492
+ st.number_input(
493
  "Max Workers per Mini Load Line",
494
  min_value=1,
495
  max_value=50,
496
+ value=DefaultConfig.MAX_PARALLEL_WORKERS_MINI_LOAD,
497
+ key='max_parallel_workers_mini_load',
498
  help="Maximum number of workers that can work simultaneously on a mini load line"
499
  )
500
 
 
513
 
514
  col1, col2, col3 = st.columns(3)
515
 
516
+ payment_options = ['bulk', 'partial']
517
+
518
  with col1:
519
+ st.selectbox(
520
  "Shift 1 (Regular) Payment",
521
+ options=payment_options,
522
+ index=payment_options.index(DefaultConfig.PAYMENT_MODE_CONFIG[ShiftType.REGULAR]),
523
+ key='payment_mode_shift_1',
524
  help="Payment mode for regular shift"
525
  )
526
 
527
  with col2:
528
+ st.selectbox(
529
  "Shift 2 (Evening) Payment",
530
+ options=payment_options,
531
+ index=payment_options.index(DefaultConfig.PAYMENT_MODE_CONFIG[ShiftType.EVENING]),
532
+ key='payment_mode_shift_2',
533
  help="Payment mode for evening shift"
534
  )
535
 
536
  with col3:
537
+ st.selectbox(
538
  "Shift 3 (Overtime) Payment",
539
+ options=payment_options,
540
+ index=payment_options.index(DefaultConfig.PAYMENT_MODE_CONFIG[ShiftType.OVERTIME]),
541
+ key='payment_mode_shift_3',
542
  help="Payment mode for overtime shift"
543
  )
544
 
 
549
  col1, col2, col3 = st.columns(3)
550
 
551
  with col1:
552
+ st.number_input(
553
  "Shift 1 (Regular) - UNICEF",
554
  min_value=0.0,
555
  max_value=200.0,
556
+ value=float(DefaultConfig.UNICEF_RATE_SHIFT_1),
557
+ key='unicef_rate_shift_1',
558
  step=0.01,
559
  format="%.2f",
560
  help="Hourly rate for UNICEF Fixed Term staff during regular shift"
561
  )
562
 
563
  with col2:
564
+ st.number_input(
565
  "Shift 2 (Evening) - UNICEF",
566
  min_value=0.0,
567
  max_value=200.0,
568
+ value=float(DefaultConfig.UNICEF_RATE_SHIFT_2),
569
+ key='unicef_rate_shift_2',
570
  step=0.01,
571
  format="%.2f",
572
  help="Hourly rate for UNICEF Fixed Term staff during evening shift"
573
  )
574
 
575
  with col3:
576
+ st.number_input(
577
  "Shift 3 (Overtime) - UNICEF",
578
  min_value=0.0,
579
  max_value=200.0,
580
+ value=float(DefaultConfig.UNICEF_RATE_SHIFT_3),
581
+ key='unicef_rate_shift_3',
582
  step=0.01,
583
  format="%.2f",
584
  help="Hourly rate for UNICEF Fixed Term staff during overtime shift"
 
588
  col1, col2, col3 = st.columns(3)
589
 
590
  with col1:
591
+ st.number_input(
592
  "Shift 1 (Regular) - Humanizer",
593
  min_value=0.0,
594
  max_value=200.0,
595
+ value=float(DefaultConfig.HUMANIZER_RATE_SHIFT_1),
596
+ key='humanizer_rate_shift_1',
597
  step=0.01,
598
  format="%.2f",
599
  help="Hourly rate for Humanizer staff during regular shift"
600
  )
601
 
602
  with col2:
603
+ st.number_input(
604
  "Shift 2 (Evening) - Humanizer",
605
  min_value=0.0,
606
  max_value=200.0,
607
+ value=float(DefaultConfig.HUMANIZER_RATE_SHIFT_2),
608
+ key='humanizer_rate_shift_2',
609
  step=0.01,
610
  format="%.2f",
611
  help="Hourly rate for Humanizer staff during evening shift"
612
  )
613
 
614
  with col3:
615
+ st.number_input(
616
  "Shift 3 (Overtime) - Humanizer",
617
  min_value=0.0,
618
  max_value=200.0,
619
+ value=float(DefaultConfig.HUMANIZER_RATE_SHIFT_3),
620
+ key='humanizer_rate_shift_3',
621
  step=0.01,
622
  format="%.2f",
623
  help="Hourly rate for Humanizer staff during overtime shift"
 
633
  st.subheader("πŸ‘₯ Employee Types")
634
  available_employee_types = ["UNICEF Fixed term", "Humanizer"]
635
 
 
 
 
636
  selected_employee_types = st.multiselect(
637
  "Select Employee Types to Include",
638
  available_employee_types,
639
+ default=st.session_state.selected_employee_types,
640
  help="Choose which employee types to include in the optimization"
641
  )
642
  st.session_state.selected_employee_types = selected_employee_types
 
646
  available_shifts = list(optimization_config.shift_code_to_name().keys())
647
  shift_names = optimization_config.shift_code_to_name()
648
 
 
 
 
649
  selected_shifts = st.multiselect(
650
  "Select Shifts to Include",
651
  available_shifts,
652
+ default=st.session_state.selected_shifts,
653
  format_func=lambda x: f"Shift {x} ({shift_names[x]})",
654
  help="Choose which shifts to include in the optimization"
655
  )
 
660
  available_lines = list(optimization_config.line_code_to_name().keys())
661
  line_names = optimization_config.line_code_to_name()
662
 
 
 
 
663
  selected_lines = st.multiselect(
664
  "Select Production Lines to Include",
665
  available_lines,
666
+ default=st.session_state.selected_lines,
667
  format_func=lambda x: f"Line {x} ({line_names[x]})",
668
  help="Choose which production lines to include in the optimization"
669
  )
 
1004
 
1005
  st.info("🧹 Clearing all cached data and previous results...")
1006
 
1007
+ # Clear optimization cache using the dedicated function
1008
+ clear_optimization_cache()
1009
+
1010
+ # Clear additional optimization-related session state
1011
  keys_to_clear = [
 
1012
  'demand_dictionary',
1013
  'kit_hierarchy_data',
1014
  'team_requirements'
 
1088
  st.info(f"πŸ‘₯ Max UNICEF/day: {st.session_state.max_unicef_per_day}, Max Humanizer/day: {st.session_state.max_humanizer_per_day}")
1089
 
1090
  # Import and run the optimization (after clearing)
1091
+ from src.models.optimizer_real import Optimizer
1092
 
1093
+ # Run the optimization using Optimizer class
1094
  with st.spinner('Optimizing workforce schedule...'):
1095
+ optimizer = Optimizer()
1096
+ results = optimizer.run_optimization()
1097
 
1098
  if results is None:
1099
  st.error("❌ Optimization failed! The problem may be infeasible with current settings.")
 
1108
  st.rerun() # Refresh to show results
1109
 
1110
  except Exception as e:
1111
+ import traceback
1112
  st.error(f"❌ Error during optimization: {str(e)}")
1113
  st.error("Please check your settings and data files.")
1114
+
1115
+ # Show detailed traceback in expander
1116
+ with st.expander("πŸ” Show detailed error traceback"):
1117
+ st.code(traceback.format_exc())
1118
+
1119
+ # Also print to console for debugging
1120
+ print("\n" + "="*60)
1121
+ print("❌ OPTIMIZATION ERROR - FULL TRACEBACK:")
1122
+ print("="*60)
1123
+ traceback.print_exc()
1124
+ print("="*60)
1125
 
1126
  def check_critical_data_issues():
1127
  """