Waqasjan123 commited on
Commit
7cd4b38
Β·
verified Β·
1 Parent(s): 9e82ee1

Upload 5 files

Browse files
Files changed (5) hide show
  1. src/calculations.py +380 -0
  2. src/config.py +27 -0
  3. src/data_loader.py +119 -0
  4. src/models.py +63 -0
  5. src/streamlit_app.py +1403 -0
src/calculations.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import pandas as pd
3
+ import numpy as np
4
+ from typing import List, Dict, Any, Tuple
5
+ from models import CartonSpecification, FactoryConfig, FluteProfile
6
+
7
+ class CorruLabEngine:
8
+ """
9
+ Stateless Logic Engine for CorruLab Enterprise.
10
+ Handles Physics, Optimization, and Costing.
11
+ """
12
+
13
+ @staticmethod
14
+ def calculate_ect_rct_method(spec: CartonSpecification, config: FactoryConfig) -> Dict[str, Any]:
15
+ """
16
+ Calculate ECT using the summation of RCT values multiplied by a Conversion Factor.
17
+ ECT = (Sum(Liner RCT) + Sum(Medium RCT * Takeup Factor)) * Efficiency * Moisture Factor
18
+ Factory RCT is in kgf, convert to kN/m: RCT(kgf) Γ— 9.81 / 152.4 = kN/m
19
+ Moisture correction: higher moisture = lower strength
20
+ """
21
+ raw_ect = 0.0
22
+ # Conversion factor: kgf to kN/m = 9.81 N/kgf Γ· 152.4 mm sample
23
+ KGF_TO_KN_M = 9.81 / 152.4
24
+
25
+ # Calculate average moisture across layers
26
+ total_moisture = sum(l.paper.moisture_pct for l in spec.layers)
27
+ avg_moisture = total_moisture / len(spec.layers) if spec.layers else 7.5
28
+
29
+ # Moisture correction factor (research-based)
30
+ # Formula: Strength Factor = 1 / (M Γ— 0.07 + 0.47)
31
+ # At 7.5% moisture (optimal), factor = 1 / (7.5 Γ— 0.07 + 0.47) = 1 / 0.995 β‰ˆ 1.0
32
+ # At 10% moisture, factor = 1 / (10 Γ— 0.07 + 0.47) = 1 / 1.17 β‰ˆ 0.85
33
+ # Normalize to 7.5% baseline
34
+ baseline_factor = 1 / (7.5 * 0.07 + 0.47) # ~1.005
35
+ current_factor = 1 / (avg_moisture * 0.07 + 0.47)
36
+ moisture_factor = current_factor / baseline_factor # Relative to optimal
37
+
38
+ explanation_steps = [
39
+ "#### 1. Paper Strength (RCT β†’ ECT)",
40
+ "Your factory measures **Ring Crush Test (RCT)** in **kgf**. We convert to ECT (kN/m):",
41
+ "",
42
+ "> **ECT (kN/m) = RCT (kgf) Γ— 9.81 Γ· 152.4**",
43
+ "",
44
+ "**Layer-by-Layer Calculation:**"
45
+ ]
46
+
47
+ for i, layer in enumerate(spec.layers):
48
+ # Convert RCT from kgf to kN/m
49
+ rct_kn_m = layer.paper.rct_cd_N * KGF_TO_KN_M
50
+
51
+ if layer.layer_type == "Liner":
52
+ contribution = rct_kn_m
53
+ raw_ect += contribution
54
+ explanation_steps.append(f"* **Liner {i+1} ({layer.paper.code})**: {contribution:.2f} kN/m (from {layer.paper.rct_cd_N:.1f} kgf)")
55
+ elif layer.layer_type == "Flute" and layer.flute_profile:
56
+ # Flute contribution = RCT Γ— Takeup Factor
57
+ contribution = rct_kn_m * layer.flute_profile.factor
58
+ raw_ect += contribution
59
+ explanation_steps.append(f"* **Flute {i+1} ({layer.paper.code})**: {contribution:.2f} kN/m _(from {layer.paper.rct_cd_N:.1f} kgf Γ— {layer.flute_profile.factor})_")
60
+
61
+ # Apply Conversion Factor (Process Loss) and Moisture Factor
62
+ final_ect = raw_ect * config.ect_conversion_factor * moisture_factor
63
+
64
+ explanation_steps.append("")
65
+ explanation_steps.append("#### 2. Reality Adjustments")
66
+ explanation_steps.append(f"* **Theoretical Sum**: {raw_ect:.2f} kN/m")
67
+ explanation_steps.append(f"* **Process Factor**: Γ—{config.ect_conversion_factor}")
68
+ explanation_steps.append("")
69
+ explanation_steps.append("#### 3. Moisture Correction")
70
+ explanation_steps.append("Paper absorbs moisture from air, which **weakens fiber bonds** and reduces compression strength.")
71
+ explanation_steps.append("")
72
+ explanation_steps.append("**The Formula:**")
73
+ explanation_steps.append("> **Strength Factor = 1 Γ· (M Γ— 0.07 + 0.47)**")
74
+ explanation_steps.append("")
75
+ explanation_steps.append("**Where the constants come from:**")
76
+ explanation_steps.append("- **0.07**: Rate at which strength decreases per 1% moisture increase")
77
+ explanation_steps.append("- **0.47**: Base factor at 0% moisture (theoretical)")
78
+ explanation_steps.append("- _Source: Georgia Tech Paper Research (SCT moisture correction formula)_")
79
+ explanation_steps.append("")
80
+ explanation_steps.append("**Your Calculation:**")
81
+ denominator = avg_moisture * 0.07 + 0.47
82
+ raw_factor = 1 / denominator
83
+ explanation_steps.append(f"```")
84
+ explanation_steps.append(f"Step 1: M Γ— 0.07 = {avg_moisture:.1f} Γ— 0.07 = {avg_moisture * 0.07:.3f}")
85
+ explanation_steps.append(f"Step 2: Add 0.47 = {avg_moisture * 0.07:.3f} + 0.47 = {denominator:.3f}")
86
+ explanation_steps.append(f"Step 3: Invert = 1 Γ· {denominator:.3f} = {raw_factor:.3f}")
87
+ explanation_steps.append(f"Step 4: Normalize to 7.5% = {moisture_factor:.3f}")
88
+ explanation_steps.append(f"```")
89
+ explanation_steps.append("")
90
+ explanation_steps.append("| Moisture % | Calculation | Strength Factor |")
91
+ explanation_steps.append("|------------|-------------|-----------------|")
92
+ explanation_steps.append("| 7.5% (Optimal) | 1Γ·(7.5Γ—0.07+0.47) | 1.00 |")
93
+ explanation_steps.append("| 9% | 1Γ·(9Γ—0.07+0.47) | 0.95 |")
94
+ explanation_steps.append("| 10% | 1Γ·(10Γ—0.07+0.47) | 0.88 |")
95
+ explanation_steps.append("| 12% | 1Γ·(12Γ—0.07+0.47) | 0.75 |")
96
+ explanation_steps.append("")
97
+ explanation_steps.append(f"**Your Result:** Avg Moisture = {avg_moisture:.1f}% β†’ Factor = **Γ—{moisture_factor:.2f}**")
98
+ if avg_moisture > 9:
99
+ explanation_steps.append(f"")
100
+ explanation_steps.append(f"> ⚠️ **High moisture ({avg_moisture:.1f}%) reduces strength by {(1-moisture_factor)*100:.0f}%**")
101
+ explanation_steps.append("")
102
+ explanation_steps.append(f"#### βœ… Final ECT: **{final_ect:.2f} kN/m**")
103
+ explanation_steps.append(f"_= {raw_ect:.2f} Γ— {config.ect_conversion_factor} Γ— {moisture_factor:.2f}_")
104
+
105
+ return {
106
+ "ect_value": final_ect,
107
+ "moisture_factor": moisture_factor,
108
+ "avg_moisture": avg_moisture,
109
+ "explanation": "\n\n".join(explanation_steps)
110
+ }
111
+
112
+ @staticmethod
113
+ def calculate_bct_mckee(spec: CartonSpecification, ect_value: float, config: FactoryConfig) -> Dict[str, Any]:
114
+ """
115
+ Calculate BCT using McKee Formula + Process Efficiency.
116
+ BCT (N) = 5.87 * ECT (kN/m) * sqrt(Perimeter (mm) * Thickness (mm)) * Efficiency
117
+ """
118
+ perimeter_mm = (2 * spec.length_mm) + (2 * spec.width_mm)
119
+
120
+ # Calculate total thickness
121
+ thickness_mm = 0.0
122
+ flute_heights = []
123
+ for layer in spec.layers:
124
+ if layer.layer_type == "Flute" and layer.flute_profile:
125
+ thickness_mm += layer.flute_profile.height_mm
126
+ flute_heights.append(f"{layer.flute_profile.height_mm} ({layer.flute_profile.name})")
127
+ else:
128
+ thickness_mm += 0.25 # Approx liner thickness
129
+
130
+ # McKee Calculation
131
+ # Convert to Imperial for the calculation to match the constant 5.87 (which is for Imperial)
132
+ ect_lb_in = ect_value * 5.71 # 1 kN/m = 5.71 lb/in
133
+ p_in = perimeter_mm / 25.4
134
+ t_in = thickness_mm / 25.4
135
+
136
+ # The constant 5.87 is specific to RSC boxes in Imperial units
137
+ bct_lbs_theoretical = 5.87 * ect_lb_in * math.sqrt(p_in * t_in)
138
+
139
+ # Apply Process Efficiency
140
+ efficiency = config.process_efficiency_pct / 100.0
141
+ bct_lbs_real = bct_lbs_theoretical * efficiency
142
+
143
+ bct_kg = bct_lbs_real * 0.453592
144
+
145
+ explanation_steps = [
146
+ "#### 1. Geometry (Metric)",
147
+ f"* **Perimeter (P)**: 2 Γ— (L + W) = 2 Γ— ({spec.length_mm} + {spec.width_mm}) = **{perimeter_mm:.0f} mm**",
148
+ f"* **Thickness (T)**: Sum of Flutes ({' + '.join(flute_heights)}) + Liners β‰ˆ **{thickness_mm:.2f} mm**",
149
+ "",
150
+ "#### 2. Unit Conversion (Imperial)",
151
+ "The standard McKee formula uses Imperial units (lbs, inches). We convert our inputs:",
152
+ f"* **ECT**: {ect_value:.2f} kN/m Γ— 5.71 = **{ect_lb_in:.2f} lb/in**",
153
+ f"* **Perimeter**: {perimeter_mm:.0f} mm Γ· 25.4 = **{p_in:.2f} in**",
154
+ f"* **Thickness**: {thickness_mm:.2f} mm Γ· 25.4 = **{t_in:.3f} in**",
155
+ "",
156
+ "#### 3. The McKee Formula",
157
+ "> BCT = 5.87 Γ— ECT Γ— √(Thickness Γ— Perimeter)",
158
+ "",
159
+ f"**Calculation:**",
160
+ f"* √(T Γ— P) = √({t_in:.3f} Γ— {p_in:.2f}) = **{math.sqrt(p_in*t_in):.2f}**",
161
+ f"* BCT = 5.87 Γ— {ect_lb_in:.2f} Γ— {math.sqrt(p_in*t_in):.2f} = **{bct_lbs_theoretical:.1f} lbs**",
162
+ "",
163
+ "#### 4. Final Result (Metric)",
164
+ f"* **Theoretical**: {bct_lbs_theoretical:.1f} lbs = **{bct_lbs_theoretical*0.453592:.1f} kg**",
165
+ f"* **Process Efficiency**: {config.process_efficiency_pct}% (Loss due to converting)",
166
+ f"#### πŸ’₯ Final BCT: {bct_lbs_theoretical*0.453592:.1f} kg Γ— {efficiency:.2f} = **{bct_kg:.1f} kg**"
167
+ ]
168
+
169
+ return {
170
+ "bct_kg": bct_kg,
171
+ "thickness_mm": thickness_mm,
172
+ "explanation": "\n".join(explanation_steps)
173
+ }
174
+
175
+ @staticmethod
176
+ def calculate_box_burst(spec: CartonSpecification) -> Dict[str, Any]:
177
+ """
178
+ Calculate Box Bursting Strength.
179
+ Sum of Liner Burst Strengths.
180
+ """
181
+ total_burst = 0.0
182
+ explanation_steps = ["#### Combined Burst Strength", "Bursting Strength measures resistance to puncture (e.g., rough handling). It is the sum of all Liner strengths:"]
183
+
184
+ for i, layer in enumerate(spec.layers):
185
+ if layer.layer_type == "Liner":
186
+ contribution = layer.paper.bursting_strength_kpa
187
+ total_burst += contribution
188
+ explanation_steps.append(f"* **Liner {i+1} ({layer.paper.code})**: {contribution:.0f} kPa")
189
+
190
+ explanation_steps.append(f"#### βœ… Total Burst: **{total_burst:.0f} kPa**")
191
+
192
+ return {
193
+ "burst_kpa": total_burst,
194
+ "explanation": "\n\n".join(explanation_steps)
195
+ }
196
+
197
+ @staticmethod
198
+ def predict_lifecycle(initial_bct_kg: float, humidity_pct: float) -> pd.DataFrame:
199
+ """
200
+ Generate Strength vs Days curve.
201
+ Degradation based on Humidity.
202
+ """
203
+
204
+ days = pd.Series(range(1, 91)) # 1 to 90 days
205
+
206
+ if humidity_pct < 50:
207
+ decay_factor = 0.02
208
+ elif humidity_pct < 70:
209
+ decay_factor = 0.05
210
+ elif humidity_pct < 85:
211
+ decay_factor = 0.10
212
+ else:
213
+ decay_factor = 0.15
214
+
215
+ k = ((humidity_pct - 30) / 100.0) * 0.5
216
+
217
+ retention = 1.0 - (k * np.log10(days + 1))
218
+ retention = np.clip(retention, 0.2, 1.0) # Min 20% strength
219
+
220
+ safe_load = initial_bct_kg * retention
221
+
222
+ return pd.DataFrame({
223
+ "Days": days,
224
+ "Safe Load (kg)": safe_load,
225
+ "Target": [initial_bct_kg * 0.6] * len(days) # Example red line
226
+ })
227
+
228
+ @staticmethod
229
+ def calculate_wastage_per_layer(spec: CartonSpecification, config: FactoryConfig) -> pd.DataFrame:
230
+ """
231
+ Calculate usage and wastage stats for each layer based on its assigned deckle.
232
+ """
233
+ results = []
234
+
235
+ sheet_w = spec.width_mm + spec.height_mm
236
+
237
+ for i, layer in enumerate(spec.layers):
238
+ deckle = layer.deckle_mm if layer.deckle_mm and layer.deckle_mm > 0 else 0
239
+
240
+ if deckle > 0:
241
+ ups = int(deckle / sheet_w)
242
+ if ups < 1: ups = 1 # Fallback to avoid div by zero, implies invalid deckle
243
+
244
+ used_width = ups * sheet_w
245
+ trim_mm = deckle - used_width
246
+ trim_pct = (trim_mm / deckle) * 100
247
+ else:
248
+ # No deckle provided? Assume perfect fit or standard logic
249
+ ups = 1
250
+ trim_mm = 0
251
+ trim_pct = 0.0
252
+
253
+ results.append({
254
+ "layer_idx": i,
255
+ "layer_type": layer.layer_type,
256
+ "paper": layer.paper.code,
257
+ "deckle_mm": deckle,
258
+ "ups": ups,
259
+ "trim_mm": trim_mm,
260
+ "trim_pct": trim_pct
261
+ })
262
+
263
+ return pd.DataFrame(results)
264
+
265
+ @staticmethod
266
+ def calculate_granular_cost(spec: CartonSpecification, config: FactoryConfig,
267
+ wastage_df: pd.DataFrame, order_qty: int) -> Dict[str, Any]:
268
+ """
269
+ Detailed Costing with specific layer wastage and process costs.
270
+ """
271
+ # 1. Paper Cost & Wastage
272
+ # Sheet Dimensions (One Box - Full Rectangle)
273
+ # Factory buys/processes FULL rectangle. Stitch cut-outs become WASTE.
274
+ sheet_length_mm = 2*spec.length_mm + 2*spec.width_mm + spec.stitch_allowance_mm
275
+ sheet_width_mm = spec.width_mm + spec.height_mm
276
+ sheet_area_m2 = (sheet_length_mm * sheet_width_mm) / 1_000_000.0
277
+
278
+ # Stitch cut-out waste (flaps above/below stitch are cut and wasted)
279
+ flap_height = min(spec.length_mm, spec.width_mm) / 2.0 # RSC standard flap
280
+ stitch_cutout_area_m2 = (spec.stitch_allowance_mm * 2 * flap_height) / 1_000_000.0
281
+
282
+ total_paper_cost_net = 0.0
283
+ total_paper_cost_gross = 0.0
284
+ paper_breakdown = []
285
+
286
+ for i, layer in enumerate(spec.layers):
287
+ # Takeup Factor
288
+ takeup = layer.flute_profile.factor if layer.flute_profile else 1.0
289
+
290
+ # Net Weight (Theoretical box only)
291
+ weight_gsm = layer.paper.gsm * takeup
292
+ weight_sheet_net_kg = weight_gsm * sheet_area_m2 / 1000.0
293
+
294
+ # Wastage Factors
295
+ row = wastage_df.loc[wastage_df['layer_idx'] == i].iloc[0]
296
+ trim_pct = row['trim_pct']
297
+
298
+ # Gross Weight Calculation:
299
+ # 1. Process Waste added first (e.g. corrugator damage)
300
+ w_process = weight_sheet_net_kg * (1 + config.wastage_process_pct/100.0)
301
+
302
+ # 2. Trim Waste (The gross paper used includes the trim area)
303
+ # If Trim is 10%, it means we paid for 100% but only used 90%.
304
+ # So Gross = Net / (1 - Trim%) -- OR simply Gross = Net * (Deckle / UsedWidth)
305
+ # Let's use the explicit ratio from the deckle calc for precision
306
+ if row['deckle_mm'] > 0 and row['ups'] > 0:
307
+ deckle_utilization = (row['ups'] * (spec.width_mm + spec.height_mm)) / row['deckle_mm']
308
+ if deckle_utilization > 0:
309
+ w_gross = w_process / deckle_utilization
310
+ else:
311
+ w_gross = w_process
312
+ else:
313
+ w_gross = w_process # No trim calc possible
314
+
315
+ # Cost
316
+ cost_net = weight_sheet_net_kg * layer.paper.rate
317
+ cost_gross = w_gross * layer.paper.rate
318
+
319
+ total_paper_cost_net += cost_net
320
+ total_paper_cost_gross += cost_gross
321
+
322
+ paper_breakdown.append({
323
+ "layer": layer.paper.code,
324
+ "gsm": layer.paper.gsm,
325
+ "cost_net": cost_net,
326
+ "cost_gross": cost_gross,
327
+ "waste_cost": cost_gross - cost_net,
328
+ "weight_net": weight_sheet_net_kg * order_qty,
329
+ "weight_gross": w_gross * order_qty
330
+ })
331
+
332
+ # 2. Fixed Wastage (Peel + Core) - REMOVED
333
+ wastage_fixed_per_box = 0.0
334
+
335
+ # 3. Weight per box (for reference)
336
+ weight_box_net = sum(l.paper.gsm * (l.flute_profile.factor if l.flute_profile else 1.0) for l in spec.layers) * sheet_area_m2 / 1000.0
337
+
338
+ # 4. Value Added Processes (Only if enabled by user)
339
+ print_cost = 0.0
340
+ uv_cost = 0.0
341
+ lam_cost = 0.0
342
+ die_cost = 0.0
343
+
344
+ if spec.has_printing:
345
+ # Rate per 1000 + Plate Cost / Qty
346
+ print_cost = (config.cost_printing_per_1000 / 1000.0) + (config.cost_printing_plate / order_qty)
347
+
348
+ if spec.has_uv:
349
+ uv_cost = config.cost_uv_per_1000 / 1000.0
350
+
351
+ if spec.has_lamination:
352
+ lam_cost = config.cost_lamination_per_1000 / 1000.0
353
+
354
+ if spec.has_die_cutting:
355
+ die_cost = (config.cost_die_cutting_per_1000 / 1000.0) + (config.cost_die_frame / order_qty)
356
+
357
+ # Total Factory Cost = Paper + Waste + Value-Add + Setup
358
+ factory_cost = (total_paper_cost_gross +
359
+ print_cost + uv_cost + lam_cost + die_cost +
360
+ (config.cost_fixed_setup/order_qty))
361
+
362
+ # Price
363
+ price = factory_cost * (1 + config.margin_pct/100.0)
364
+
365
+ return {
366
+ "paper_cost_net": total_paper_cost_net,
367
+ "paper_cost_gross": total_paper_cost_gross,
368
+ "trim_and_process_waste_cost": total_paper_cost_gross - total_paper_cost_net,
369
+ "print_cost": print_cost,
370
+ "uv_cost": uv_cost,
371
+ "lam_cost": lam_cost,
372
+ "die_cost": die_cost,
373
+ "setup_cost": config.cost_fixed_setup/order_qty,
374
+ "factory_cost": factory_cost,
375
+ "price": price,
376
+ "weight_per_box": weight_box_net,
377
+ "stitch_cutout_waste_kg": stitch_cutout_area_m2 * sum(l.paper.gsm * (l.flute_profile.factor if l.flute_profile else 1.0) for l in spec.layers) / 1000.0 * order_qty,
378
+ "stitch_cutout_area_m2": stitch_cutout_area_m2,
379
+ "breakdown": paper_breakdown
380
+ }
src/config.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration settings for Usman Packaging MRP System.
3
+ Controls data source switching between local development and HuggingFace production.
4
+ """
5
+ import os
6
+
7
+ # ============================================================================
8
+ # DEVELOPMENT MODE TOGGLE
9
+ # ============================================================================
10
+ # True = Use local data folder (for development)
11
+ # False = Fetch from Hugging Face dataset repository (for production/HF Spaces)
12
+ #
13
+ # Set via environment variable in HF Space settings:
14
+ # DEV_MODE=false
15
+ # ============================================================================
16
+ DEV_MODE = os.getenv("DEV_MODE", "true").lower() == "true"
17
+
18
+ # ============================================================================
19
+ # HUGGING FACE CONFIGURATION
20
+ # ============================================================================
21
+ # Your HuggingFace dataset repository containing paper_db.json and factory_settings.json
22
+ HF_REPO_ID = os.getenv("HF_REPO_ID", "Waqasjan123/usman-packaging-data")
23
+ HF_REPO_TYPE = "dataset"
24
+
25
+ # Data file names (used by both local and HF loading)
26
+ PAPER_DB_FILENAME = "paper_db.json"
27
+ FACTORY_SETTINGS_FILENAME = "factory_settings.json"
src/data_loader.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data Loader - Handles loading data from local storage or HuggingFace.
3
+ Automatically switches based on DEV_MODE configuration.
4
+ """
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Tuple, List
8
+
9
+ from config import (
10
+ DEV_MODE,
11
+ HF_REPO_ID,
12
+ HF_REPO_TYPE,
13
+ PAPER_DB_FILENAME,
14
+ FACTORY_SETTINGS_FILENAME
15
+ )
16
+ from models import FluteProfile, PaperGrade, FactoryConfig
17
+
18
+ # Get the directory where this file is located
19
+ BASE_DIR = Path(__file__).parent
20
+
21
+
22
+ def _load_from_local() -> Tuple[List[PaperGrade], "FactoryConfig", List[FluteProfile]]:
23
+ """Load data from local data/ folder."""
24
+ print("πŸ“ Loading data from LOCAL storage...")
25
+
26
+ paper_db_path = BASE_DIR / "data" / PAPER_DB_FILENAME
27
+ factory_settings_path = BASE_DIR / "data" / FACTORY_SETTINGS_FILENAME
28
+
29
+ with open(paper_db_path, "r") as f:
30
+ paper_db = [PaperGrade(**p) for p in json.load(f)]
31
+
32
+ with open(factory_settings_path, "r") as f:
33
+ fs_data = json.load(f)
34
+
35
+ flutes, factory_config = _parse_factory_settings(fs_data)
36
+
37
+ print(f"βœ… Loaded {len(paper_db)} paper grades, {len(flutes)} flute profiles")
38
+ return paper_db, factory_config, flutes
39
+
40
+
41
+ def _load_from_huggingface() -> Tuple[List[PaperGrade], "FactoryConfig", List[FluteProfile]]:
42
+ """Load data from HuggingFace dataset repository."""
43
+ print(f"☁️ Loading data from HuggingFace: {HF_REPO_ID}...")
44
+
45
+ try:
46
+ from huggingface_hub import hf_hub_download
47
+ except ImportError:
48
+ raise ImportError(
49
+ "huggingface_hub is required for production mode. "
50
+ "Install with: pip install huggingface_hub"
51
+ )
52
+
53
+ # Download files from HuggingFace (cached automatically)
54
+ paper_db_path = hf_hub_download(
55
+ repo_id=HF_REPO_ID,
56
+ filename=PAPER_DB_FILENAME,
57
+ repo_type=HF_REPO_TYPE
58
+ )
59
+
60
+ factory_settings_path = hf_hub_download(
61
+ repo_id=HF_REPO_ID,
62
+ filename=FACTORY_SETTINGS_FILENAME,
63
+ repo_type=HF_REPO_TYPE
64
+ )
65
+
66
+ with open(paper_db_path, "r") as f:
67
+ paper_db = [PaperGrade(**p) for p in json.load(f)]
68
+
69
+ with open(factory_settings_path, "r") as f:
70
+ fs_data = json.load(f)
71
+
72
+ flutes, factory_config = _parse_factory_settings(fs_data)
73
+
74
+ print(f"βœ… Loaded {len(paper_db)} paper grades, {len(flutes)} flute profiles from HuggingFace")
75
+ return paper_db, factory_config, flutes
76
+
77
+
78
+ def _parse_factory_settings(fs_data: dict) -> Tuple[List[FluteProfile], "FactoryConfig"]:
79
+ """Parse factory settings JSON into typed objects."""
80
+ flutes = [FluteProfile(**fp) for fp in fs_data['flutes']]
81
+ wastage = fs_data['wastage']
82
+ costs = fs_data['costs']
83
+ reels = fs_data['reels']
84
+
85
+ factory_config = FactoryConfig(
86
+ wastage_process_pct=wastage['process_pct'],
87
+ cost_conversion_per_kg=costs['conversion_per_kg'],
88
+ cost_fixed_setup=costs['fixed_setup'],
89
+ # Value-Add Costs (optional processes)
90
+ cost_printing_per_1000=costs.get('printing_per_1000', 0.0),
91
+ cost_printing_plate=costs.get('printing_plate', 0.0),
92
+ cost_uv_per_1000=costs.get('uv_per_1000', 0.0),
93
+ cost_lamination_per_1000=costs.get('lamination_per_1000', 0.0),
94
+ cost_die_cutting_per_1000=costs.get('die_cutting_per_1000', 0.0),
95
+ cost_die_frame=costs.get('die_frame', 0.0),
96
+ margin_pct=costs['margin_pct'],
97
+ process_efficiency_pct=costs.get('process_efficiency_pct', 85.0),
98
+ ect_conversion_factor=costs.get('ect_conversion_factor', 0.85),
99
+ currency=costs['currency'],
100
+ available_reel_sizes=reels
101
+ )
102
+
103
+ return flutes, factory_config
104
+
105
+
106
+ def load_all_data() -> Tuple[List[PaperGrade], "FactoryConfig", List[FluteProfile]]:
107
+ """
108
+ Main entry point for loading data.
109
+ Automatically chooses local or HuggingFace based on DEV_MODE.
110
+
111
+ Returns:
112
+ Tuple of (paper_db, factory_config, flute_profiles)
113
+ """
114
+ print(f"πŸ”§ DEV_MODE = {DEV_MODE}")
115
+
116
+ if DEV_MODE:
117
+ return _load_from_local()
118
+ else:
119
+ return _load_from_huggingface()
src/models.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from dataclasses import dataclass, field
3
+ from typing import List, Optional
4
+
5
+ @dataclass
6
+ class FluteProfile:
7
+ name: str
8
+ height_mm: float
9
+ factor: float # Direct input now
10
+ pitch_mm: float = 0.0 # Optional/Reference only now
11
+
12
+ def __post_init__(self):
13
+ pass # No calculation needed
14
+
15
+ @dataclass
16
+ class PaperGrade:
17
+ code: str
18
+ gsm: int
19
+ rate: float
20
+ rct_cd_N: float # Ring Crush CD in Newtons (N)
21
+ rct_md_N: float # Ring Crush MD in Newtons (N)
22
+ bursting_strength_kpa: float # kPa
23
+ ash_pct: float
24
+ moisture_pct: float
25
+
26
+ @dataclass
27
+ class FactoryConfig:
28
+ wastage_process_pct: float
29
+ cost_conversion_per_kg: float
30
+ cost_fixed_setup: float
31
+ margin_pct: float
32
+ process_efficiency_pct: float
33
+ currency: str
34
+ available_reel_sizes: List[int]
35
+ # Value-Add Costs (optional processes)
36
+ cost_printing_per_1000: float = 0.0
37
+ cost_printing_plate: float = 0.0
38
+ cost_uv_per_1000: float = 0.0
39
+ cost_lamination_per_1000: float = 0.0
40
+ cost_die_cutting_per_1000: float = 0.0
41
+ cost_die_frame: float = 0.0
42
+ ect_conversion_factor: float = 0.85
43
+
44
+ @dataclass
45
+ class LayerInput:
46
+ layer_type: str # "Liner" or "Flute"
47
+ paper: PaperGrade
48
+ flute_profile: Optional[FluteProfile] = None
49
+ deckle_mm: Optional[int] = None # User provided reel size for this layer
50
+
51
+ @dataclass
52
+ class CartonSpecification:
53
+ length_mm: float
54
+ width_mm: float
55
+ height_mm: float
56
+ layers: List[LayerInput]
57
+ ply_type: str # "3 Ply", "5 Ply", "2+1 (Bleach Card)"
58
+ stitch_allowance_mm: float = 40.0
59
+ # Process Flags
60
+ has_printing: bool = False
61
+ has_uv: bool = False
62
+ has_lamination: bool = False
63
+ has_die_cutting: bool = False
src/streamlit_app.py ADDED
@@ -0,0 +1,1403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import json
4
+ import plotly.graph_objects as go
5
+ import plotly.express as px
6
+ from datetime import datetime
7
+ from typing import List, Dict
8
+ from pathlib import Path
9
+
10
+ from models import FluteProfile, PaperGrade, FactoryConfig, LayerInput, CartonSpecification
11
+ from calculations import CorruLabEngine
12
+ from data_loader import load_all_data
13
+ from config import DEV_MODE
14
+
15
+ # --- CONFIG & SETUP ---
16
+ st.set_page_config(page_title="NRP - New Royal Printing", page_icon="πŸ“¦", layout="wide", initial_sidebar_state="expanded")
17
+
18
+ # Get the directory where app.py is located (works on local + Hugging Face)
19
+ BASE_DIR = Path(__file__).parent
20
+
21
+ # --- LOAD DATA ---
22
+ # Data loading now handled by data_loader.py
23
+ # Automatically switches between local (DEV_MODE=true) and HuggingFace (DEV_MODE=false)
24
+ try:
25
+ PAPER_DB, FACTORY_CONFIG, FLUTE_PROFILES = load_all_data()
26
+ except Exception as e:
27
+ st.error(f"Critical Error Loading Data: {e}")
28
+ st.stop()
29
+
30
+ # --- STYLING ---
31
+ st.markdown("""
32
+ <style>
33
+ /* ========== FORCE HIDE NUMBER INPUT STEPPERS ========== */
34
+ /* Multiple aggressive selectors to ensure buttons are hidden */
35
+ button[kind="secondaryFormSubmit"],
36
+ button[data-testid="stNumberInputStepUp"],
37
+ button[data-testid="stNumberInputStepDown"],
38
+ [data-testid="stNumberInput"] button,
39
+ [data-testid="stNumberInput"] div[data-testid="StyledLinkIconContainer"],
40
+ div[data-baseweb="input"] button {
41
+ display: none !important;
42
+ visibility: hidden !important;
43
+ width: 0 !important;
44
+ height: 0 !important;
45
+ padding: 0 !important;
46
+ margin: 0 !important;
47
+ opacity: 0 !important;
48
+ }
49
+
50
+ /* Remove button container */
51
+ [data-testid="stNumberInput"] > div > div:nth-child(2),
52
+ [data-testid="stNumberInput"] > div > div > div:nth-child(2) {
53
+ display: none !important;
54
+ }
55
+
56
+ /* Make input full width */
57
+ [data-testid="stNumberInput"] input,
58
+ [data-testid="stNumberInput"] > div > div > div > input {
59
+ width: 100% !important;
60
+ padding-right: 8px !important;
61
+ }
62
+
63
+ /* Browser native spin buttons */
64
+ input::-webkit-outer-spin-button,
65
+ input::-webkit-inner-spin-button {
66
+ -webkit-appearance: none !important;
67
+ margin: 0 !important;
68
+ display: none !important;
69
+ }
70
+ input[type=number] {
71
+ -moz-appearance: textfield !important;
72
+ }
73
+
74
+ /* ========== MODERN PROFESSIONAL STYLING ========== */
75
+
76
+ /* Clean input styling - LARGER FOR READABILITY */
77
+ [data-testid="stNumberInput"] input {
78
+ border: 1px solid #e0e0e0 !important;
79
+ border-radius: 6px !important;
80
+ padding: 10px 14px !important;
81
+ font-size: 16px !important;
82
+ font-weight: 500 !important;
83
+ background: white !important;
84
+ transition: all 0.2s ease !important;
85
+ min-height: 42px !important;
86
+ }
87
+
88
+ /* Sidebar specific - full width inputs */
89
+ [data-testid="stSidebar"] [data-testid="stNumberInput"] input {
90
+ width: 100% !important;
91
+ font-size: 15px !important;
92
+ }
93
+
94
+ [data-testid="stSidebar"] [data-testid="stTextInput"] input {
95
+ font-size: 14px !important;
96
+ padding: 10px !important;
97
+ }
98
+
99
+ [data-testid="stNumberInput"] input:focus {
100
+ border-color: #1976d2 !important;
101
+ box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1) !important;
102
+ outline: none !important;
103
+ }
104
+
105
+ /* Select box styling */
106
+ [data-testid="stSelectbox"] > div > div {
107
+ border-radius: 6px !important;
108
+ border: 1px solid #e0e0e0 !important;
109
+ }
110
+
111
+ /* Column spacing optimization */
112
+ div[data-testid="column"] {
113
+ padding: 0px 6px !important;
114
+ }
115
+
116
+ /* Section cards */
117
+ .stContainer > div {
118
+ background: #ffffff;
119
+ border-radius: 12px;
120
+ padding: 20px;
121
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
122
+ margin-bottom: 16px;
123
+ }
124
+
125
+ /* Metric cards */
126
+ .metric-card {
127
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
128
+ color: white;
129
+ padding: 24px;
130
+ border-radius: 12px;
131
+ box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
132
+ margin-bottom: 16px;
133
+ }
134
+
135
+ .metric-title {
136
+ color: rgba(255,255,255,0.9);
137
+ font-size: 0.85rem;
138
+ text-transform: uppercase;
139
+ letter-spacing: 1.2px;
140
+ font-weight: 500;
141
+ }
142
+
143
+ .metric-value {
144
+ color: white;
145
+ font-size: 2rem;
146
+ font-weight: 700;
147
+ margin-top: 8px;
148
+ }
149
+
150
+ /* Header styling */
151
+ .main-header {
152
+ font-size: 2.4rem;
153
+ font-weight: 800;
154
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
155
+ -webkit-background-clip: text;
156
+ -webkit-text-fill-color: transparent;
157
+ background-clip: text;
158
+ margin-bottom: 24px;
159
+ }
160
+
161
+ /* Modern tabs */
162
+ .stTabs [data-baseweb="tab-list"] {
163
+ gap: 4px;
164
+ background: #f8f9fa;
165
+ padding: 4px;
166
+ border-radius: 10px;
167
+ }
168
+
169
+ .stTabs [data-baseweb="tab"] {
170
+ height: 48px;
171
+ background: transparent;
172
+ border-radius: 8px;
173
+ color: #666;
174
+ font-weight: 500;
175
+ transition: all 0.2s ease;
176
+ }
177
+
178
+ .stTabs [data-baseweb="tab"]:hover {
179
+ background: rgba(102, 126, 234, 0.1);
180
+ color: #667eea;
181
+ }
182
+
183
+ .stTabs [aria-selected="true"] {
184
+ background: white !important;
185
+ color: #667eea !important;
186
+ box-shadow: 0 2px 4px rgba(0,0,0,0.08);
187
+ border: none !important;
188
+ }
189
+
190
+ /* Divider styling */
191
+ hr {
192
+ margin: 24px 0;
193
+ border: none;
194
+ height: 1px;
195
+ background: linear-gradient(90deg, transparent, #e0e0e0, transparent);
196
+ }
197
+
198
+ /* Better spacing for layer sections */
199
+ .element-container {
200
+ margin-bottom: 8px;
201
+ }
202
+ </style>
203
+ """, unsafe_allow_html=True)
204
+
205
+ # --- HELPERS ---
206
+ def to_mm(val, unit):
207
+ return val * 25.4 if unit == "inch" else val
208
+
209
+ def from_mm(val_mm, unit):
210
+ return val_mm / 25.4 if unit == "inch" else val_mm
211
+
212
+ def unit_label(label, unit):
213
+ return f"{label} ({unit})"
214
+
215
+ # Unit Switch Handler
216
+ def update_units():
217
+ # Called AFTER the 'unit' state has changed.
218
+ new_unit = st.session_state.unit
219
+ prev_unit = st.session_state.get('prev_unit', 'mm')
220
+
221
+ if prev_unit != new_unit:
222
+ factor = 1.0
223
+ if prev_unit == 'mm' and new_unit == 'inch':
224
+ factor = 1/25.4
225
+ elif prev_unit == 'inch' and new_unit == 'mm':
226
+ factor = 25.4
227
+
228
+ if 'length_input' in st.session_state:
229
+ st.session_state.length_input *= factor
230
+ if 'width_input' in st.session_state:
231
+ st.session_state.width_input *= factor
232
+ if 'height_input' in st.session_state:
233
+ st.session_state.height_input *= factor
234
+
235
+ st.session_state.prev_unit = new_unit
236
+
237
+ if 'prev_unit' not in st.session_state:
238
+ st.session_state.prev_unit = "mm"
239
+
240
+ # --- SIDEBAR ---
241
+ with st.sidebar:
242
+ st.image("https://cdn-icons-png.flaticon.com/512/679/679821.png", width=60)
243
+ st.title("NRP - New Royal Printing")
244
+ st.caption("Advanced MRP System")
245
+
246
+ st.divider()
247
+
248
+ st.subheader("πŸ“ Units")
249
+ unit_system = st.radio("Display Units", ["mm", "inch"], horizontal=True, key="unit", on_change=update_units) # Global Unit State
250
+
251
+ st.divider()
252
+
253
+ st.subheader("πŸ“ Job Details")
254
+ cust_name = st.text_input("Customer", "New Client")
255
+ quote_ref = st.text_input("Quote Ref", f"Q-{datetime.now().strftime('%Y%m%d')}")
256
+ order_qty = st.number_input("Order Quantity", min_value=1, value=5000)
257
+
258
+ st.divider()
259
+
260
+ with st.expander("🏭 Factory Settings", expanded=False):
261
+ st.caption("These settings affect wastage and strength calculations.")
262
+
263
+ st.markdown("**πŸ“‰ Wastage**")
264
+ FACTORY_CONFIG.wastage_process_pct = st.number_input(
265
+ "Process Waste (%)",
266
+ value=FACTORY_CONFIG.wastage_process_pct,
267
+ min_value=0.0, max_value=20.0, step=0.5,
268
+ help="Running waste from corrugator"
269
+ )
270
+
271
+ st.markdown("**πŸ’ͺ Strength Factors**")
272
+ FACTORY_CONFIG.ect_conversion_factor = st.number_input(
273
+ "ECT Factor",
274
+ value=FACTORY_CONFIG.ect_conversion_factor,
275
+ min_value=0.5, max_value=1.2, step=0.01,
276
+ help="Lab-to-real ECT reduction (0.80-0.90)"
277
+ )
278
+ FACTORY_CONFIG.process_efficiency_pct = st.number_input(
279
+ "BCT Efficiency (%)",
280
+ value=FACTORY_CONFIG.process_efficiency_pct,
281
+ min_value=50.0, max_value=100.0, step=1.0,
282
+ help="BCT process efficiency"
283
+ )
284
+
285
+ st.markdown("**πŸ“ Reel Sizes (mm)**")
286
+ reels_str = st.text_input(
287
+ "Available Reels",
288
+ ", ".join(map(str, FACTORY_CONFIG.available_reel_sizes)),
289
+ help="Comma-separated deckle sizes"
290
+ )
291
+ try:
292
+ FACTORY_CONFIG.available_reel_sizes = [int(x.strip()) for x in reels_str.split(",") if x.strip()]
293
+ except:
294
+ st.error("Invalid reel format")
295
+
296
+ st.markdown("**🌊 Flute Takeup**")
297
+ for fp in FLUTE_PROFILES:
298
+ fp.factor = st.number_input(
299
+ f"{fp.name} Factor",
300
+ value=fp.factor,
301
+ key=f"f_{fp.name}",
302
+ step=0.01,
303
+ min_value=1.0, max_value=2.0,
304
+ help=f"Paper consumed per meter"
305
+ )
306
+
307
+ with st.expander("πŸ’Έ Process Costs (Rs)", expanded=False):
308
+ st.caption("Per-unit costs for value-added processes. Rates are per 1000 boxes.")
309
+
310
+ FACTORY_CONFIG.cost_printing_per_1000 = st.number_input("πŸ–¨οΈ Printing / 1000", value=FACTORY_CONFIG.cost_printing_per_1000)
311
+ FACTORY_CONFIG.cost_printing_plate = st.number_input("πŸ–¨οΈ Print Plate (Fixed)", value=FACTORY_CONFIG.cost_printing_plate)
312
+ FACTORY_CONFIG.cost_uv_per_1000 = st.number_input("✨ UV Coating / 1000", value=FACTORY_CONFIG.cost_uv_per_1000)
313
+ FACTORY_CONFIG.cost_lamination_per_1000 = st.number_input("πŸ“„ Lamination / 1000", value=FACTORY_CONFIG.cost_lamination_per_1000)
314
+ FACTORY_CONFIG.cost_die_cutting_per_1000 = st.number_input("βœ‚οΈ Die Cutting / 1000", value=FACTORY_CONFIG.cost_die_cutting_per_1000)
315
+ FACTORY_CONFIG.cost_die_frame = st.number_input("βœ‚οΈ Die Frame (Fixed)", value=FACTORY_CONFIG.cost_die_frame)
316
+
317
+ # --- MAIN LAYOUT ---
318
+ st.markdown("<div class='main-header'>πŸ“¦ New Royal Printing - MRP</div>", unsafe_allow_html=True)
319
+
320
+ tabs = st.tabs(["βš—οΈ The Lab (Specs)", "πŸ’ͺ Engineering & Health", "βš™οΈ Production & Waste", "πŸ’° Financials", "πŸ“„ Reports"])
321
+
322
+ # --- HELPERS ---
323
+
324
+ def plot_deckle_visualization(deckle_mm, sheet_width_mm, sheet_length_mm, ups, layer_name, utilization_pct):
325
+ """
326
+ Creates a 2D visual representation of Deckle Utilization.
327
+ X-axis = Deckle width (how many sheets fit across the reel)
328
+ Y-axis = Sheet length (the full sheet dimension)
329
+ """
330
+ fig = go.Figure()
331
+
332
+ # Dimensions
333
+ used_width = ups * sheet_width_mm
334
+ trim_width = deckle_mm - used_width
335
+
336
+ # Scale for display (keep aspect ratio reasonable)
337
+ scale_y = min(1.0, 400 / sheet_length_mm) if sheet_length_mm > 0 else 1.0
338
+ display_height = sheet_length_mm * scale_y
339
+
340
+ # Draw "Ups" (Good Sheets)
341
+ for i in range(ups):
342
+ x0 = i * sheet_width_mm
343
+ x1 = x0 + sheet_width_mm
344
+
345
+ fig.add_shape(
346
+ type="rect",
347
+ x0=x0, y0=0, x1=x1, y1=display_height,
348
+ line=dict(color="darkgreen", width=2),
349
+ fillcolor="rgba(46, 204, 113, 0.6)",
350
+ )
351
+
352
+ # Annotation for sheet
353
+ fig.add_annotation(
354
+ x=x0 + (sheet_width_mm/2),
355
+ y=display_height/2,
356
+ text=f"<b>Sheet {i+1}</b><br>{sheet_width_mm:.0f} Γ— {sheet_length_mm:.0f}mm",
357
+ showarrow=False,
358
+ font=dict(color="white", size=11)
359
+ )
360
+
361
+ # Draw Trim (Wastage)
362
+ if trim_width > 1: # Only show if more than 1mm
363
+ x0_trim = used_width
364
+ x1_trim = deckle_mm
365
+
366
+ fig.add_shape(
367
+ type="rect",
368
+ x0=x0_trim, y0=0, x1=x1_trim, y1=display_height,
369
+ line=dict(color="red", width=2),
370
+ fillcolor="rgba(231, 76, 60, 0.8)",
371
+ )
372
+ # Annotation for trim
373
+ fig.add_annotation(
374
+ x=x0_trim + (trim_width/2),
375
+ y=display_height/2,
376
+ text=f"<b>TRIM</b><br>{trim_width:.0f}mm<br>({100-utilization_pct:.1f}%)",
377
+ showarrow=False,
378
+ font=dict(color="white", size=10)
379
+ )
380
+
381
+ # Add dimension arrows/labels
382
+ # Deckle dimension (top)
383
+ fig.add_annotation(
384
+ x=deckle_mm/2, y=display_height + 20,
385
+ text=f"<b>Reel Deckle: {deckle_mm}mm</b>",
386
+ showarrow=False, font=dict(size=12, color="#333")
387
+ )
388
+
389
+ # Sheet width dimension (bottom)
390
+ fig.add_annotation(
391
+ x=sheet_width_mm/2, y=-25,
392
+ text=f"W+H = {sheet_width_mm:.0f}mm",
393
+ showarrow=False, font=dict(size=10, color="#666")
394
+ )
395
+
396
+ # Config
397
+ fig.update_layout(
398
+ title=dict(
399
+ text=f"<b>{layer_name}</b> | Utilization: <span style='color:{'green' if utilization_pct > 95 else 'orange' if utilization_pct > 85 else 'red'}'>{utilization_pct:.1f}%</span>",
400
+ font=dict(size=14)
401
+ ),
402
+ xaxis=dict(
403
+ range=[-10, deckle_mm * 1.05],
404
+ title="← Deckle Width (mm) β†’",
405
+ showgrid=True, gridcolor='rgba(0,0,0,0.1)',
406
+ dtick=200
407
+ ),
408
+ yaxis=dict(
409
+ range=[-40, display_height + 40],
410
+ title="Sheet Length",
411
+ showgrid=True, gridcolor='rgba(0,0,0,0.1)',
412
+ showticklabels=False
413
+ ),
414
+ height=280,
415
+ margin=dict(l=60, r=20, t=50, b=50),
416
+ plot_bgcolor='rgba(248,248,248,1)',
417
+ )
418
+
419
+ return fig
420
+
421
+ # --- TAB 1: THE LAB ---
422
+ with tabs[0]:
423
+ with st.container():
424
+ st.markdown("### 1. Carton Specification")
425
+ # Split into two rows for better spacing
426
+ r1_c1, r1_c2 = st.columns(2)
427
+ with r1_c1: ply_type = st.selectbox("Board Construction", ["3 Ply (Single Wall)", "5 Ply (Double Wall)", "2+1 (Bleach Card)"])
428
+
429
+ r2_c1, r2_c2, r2_c3 = st.columns(3)
430
+
431
+ # Smart Defaults based on Unit
432
+ # Note: 'value' in st.number_input is only used if key is NOT in session_state.
433
+ def_l = 15.75 if unit_system == "inch" else 400.0
434
+ def_w = 11.81 if unit_system == "inch" else 300.0
435
+ def_h = 11.81 if unit_system == "inch" else 300.0
436
+
437
+ with r2_c1: l_input = st.number_input(unit_label("Length", unit_system), value=def_l, key="length_input")
438
+ with r2_c2: w_input = st.number_input(unit_label("Width", unit_system), value=def_w, key="width_input")
439
+ with r2_c3: h_input = st.number_input(unit_label("Height", unit_system), value=def_h, key="height_input")
440
+
441
+ # Convert inputs to MM for internal logic
442
+ l_mm = to_mm(l_input, unit_system)
443
+ w_mm = to_mm(w_input, unit_system)
444
+ h_mm = to_mm(h_input, unit_system)
445
+
446
+
447
+ st.markdown("#### Process Selection")
448
+ cp1, cp2, cp3, cp4 = st.columns(4)
449
+ has_print = cp1.checkbox("Printing")
450
+ has_uv = cp2.checkbox("UV Coating")
451
+ has_lam = cp3.checkbox("Lamination")
452
+ has_die = cp4.checkbox("Die Cutting")
453
+
454
+ st.divider()
455
+
456
+ with st.container():
457
+ st.markdown("### 2. Paper Composition & Deckle")
458
+
459
+ # Define Layer Structure
460
+ if "3 Ply" in ply_type:
461
+ layer_defs = [("Top Liner", "Liner"), ("Flute", "Flute"), ("Bottom Liner", "Liner")]
462
+ elif "2+1" in ply_type:
463
+ layer_defs = [("Top Liner (Bleach Card)", "Liner"), ("Flute", "Flute"), ("Bottom Liner", "Liner")]
464
+ else:
465
+ layer_defs = [("Top Liner", "Liner"), ("Flute 1", "Flute"), ("Middle Liner", "Liner"), ("Flute 2", "Flute"), ("Bottom Liner", "Liner")]
466
+
467
+ selected_layers = []
468
+
469
+ for i, (name, l_type) in enumerate(layer_defs):
470
+ with st.expander(f"**{name}**", expanded=True):
471
+ # Flute Profile Selection (if applicable)
472
+ if l_type == "Flute":
473
+ f_name = st.selectbox("Flute Profile", [f.name for f in FLUTE_PROFILES], key=f"fp_{i}")
474
+ flute_prof = next(f for f in FLUTE_PROFILES if f.name == f_name)
475
+ else:
476
+ flute_prof = None
477
+
478
+ # Paper Selection
479
+ paper_opts = [f"{p.code} {p.gsm}GSM" for p in PAPER_DB]
480
+
481
+ # Smart Defaults
482
+ sel_idx = 0
483
+ if "2+1" in ply_type and i == 0:
484
+ try: sel_idx = next(idx for idx, p in enumerate(PAPER_DB) if p.code == "BC")
485
+ except: sel_idx = 0
486
+ elif i == 1:
487
+ sel_idx = 1
488
+
489
+ sel_paper_str = st.selectbox("Paper Grade", paper_opts, index=sel_idx, key=f"p_{i}")
490
+ sel_paper = next(p for p in PAPER_DB if f"{p.code} {p.gsm}GSM" == sel_paper_str)
491
+
492
+ # Primary Inputs - Clean 3-column layout
493
+ col1, col2, col3 = st.columns(3)
494
+ with col1:
495
+ gsm = st.number_input("GSM", value=sel_paper.gsm, key=f"g_{i}", help="Grams per Square Meter")
496
+ with col2:
497
+ rate = st.number_input("Rate (per kg)", value=sel_paper.rate, key=f"rt_{i}")
498
+ with col3:
499
+ # Deckle Logic: Input follows Unit System
500
+ def_deckle = 47.0 if unit_system == "inch" else 1200.0
501
+ deckle_input = st.number_input(unit_label("Deckle", unit_system), value=def_deckle, step=1.0, key=f"dk_{i}", help="Reel Width")
502
+ deckle_val_mm = to_mm(deckle_input, unit_system)
503
+
504
+ # Technical Specs - 4-column layout
505
+ st.markdown("**Technical Specifications**")
506
+ tc1, tc2, tc3, tc4 = st.columns(4)
507
+ with tc1:
508
+ rct_cd = st.number_input("RCT-CD (kgf)", value=sel_paper.rct_cd_N, key=f"rcd_{i}", help="Ring Crush Test CD (kgf)")
509
+ with tc2:
510
+ rct_md = st.number_input("RCT-MD (kgf)", value=sel_paper.rct_md_N, key=f"rmd_{i}", help="Ring Crush Test MD (kgf)")
511
+ with tc3:
512
+ burst = st.number_input("Burst (kPa)", value=sel_paper.bursting_strength_kpa, key=f"bst_{i}")
513
+ with tc4:
514
+ moist = st.number_input("Moisture %", value=sel_paper.moisture_pct, key=f"mst_{i}")
515
+
516
+ # Create Paper Object with overrides
517
+ final_paper = PaperGrade(sel_paper.code, gsm, rate, rct_cd, rct_md, burst, 0.0, moist)
518
+ selected_layers.append(LayerInput(l_type, final_paper, flute_prof, deckle_mm=int(deckle_val_mm)))
519
+
520
+ # Build Spec Object
521
+ CARTON_SPEC = CartonSpecification(l_mm, w_mm, h_mm, selected_layers, ply_type,
522
+ has_printing=has_print, has_uv=has_uv,
523
+ has_lamination=has_lam, has_die_cutting=has_die)
524
+
525
+ # --- TAB 2: ENGINEERING ---
526
+ with tabs[1]:
527
+ st.markdown("### πŸ”¬ Physics-Based Analysis")
528
+
529
+ col_eng1, col_eng2 = st.columns([1, 2])
530
+
531
+ # Safety Factor Input
532
+ with col_eng2:
533
+ safety_factor = st.slider("Stacking Safety Factor (SF)", min_value=1.0, max_value=6.0, value=5.0, step=0.5, help="3=Short Term, 5=Long Term/High Humidity")
534
+
535
+ # Calculate ECT & BCT & Burst
536
+ # Updated to include conversion factor from config
537
+ ect_res = CorruLabEngine.calculate_ect_rct_method(CARTON_SPEC, FACTORY_CONFIG)
538
+ bct_res = CorruLabEngine.calculate_bct_mckee(CARTON_SPEC, ect_res['ect_value'], FACTORY_CONFIG)
539
+ burst_res = CorruLabEngine.calculate_box_burst(CARTON_SPEC)
540
+
541
+ with col_eng1:
542
+ st.markdown(f"""
543
+ <div class='metric-card'>
544
+ <div class='metric-title'>Edge Crush Test (ECT)</div>
545
+ <div class='metric-value'>{ect_res['ect_value']:.2f} kN/m</div>
546
+ </div>
547
+ """, unsafe_allow_html=True)
548
+ with st.expander("πŸŽ“ ECT Calculation"):
549
+ st.markdown(ect_res['explanation'])
550
+
551
+ st.markdown(f"""
552
+ <div class='metric-card' style='border-left: 5px solid #43a047;'>
553
+ <div class='metric-title'>Safe Stacking Load</div>
554
+ <div class='metric-value'>{bct_res['bct_kg']/safety_factor:.0f} kg</div>
555
+ <div style='margin-top:8px; font-size:0.9rem; color:#eee;'>Collapse Load (BCT): <b>{bct_res['bct_kg']:.0f} kg</b> (SF={safety_factor})</div>
556
+ </div>
557
+ """, unsafe_allow_html=True)
558
+ with st.expander("πŸŽ“ BCT Calculation"):
559
+ st.markdown(bct_res['explanation'])
560
+
561
+ st.markdown(f"""
562
+ <div class='metric-card' style='border-left: 5px solid #ff9800;'>
563
+ <div class='metric-title'>Box Bursting Strength</div>
564
+ <div class='metric-value'>{burst_res['burst_kpa']:.0f} kPa</div>
565
+ </div>
566
+ """, unsafe_allow_html=True)
567
+ with st.expander("πŸŽ“ Burst Calculation"):
568
+ st.markdown(burst_res['explanation'])
569
+
570
+ with col_eng2:
571
+ st.markdown("#### πŸ“‰ Lifecycle Simulation (Strength vs Time)")
572
+ humidity = st.slider("Storage Humidity (%)", 20, 95, 60, help="Higher humidity accelerates strength degradation.")
573
+
574
+ # Calculate Safe Load for Graph
575
+ safe_load_kg = bct_res['bct_kg'] / safety_factor
576
+
577
+ # Use Safe Load for prediction
578
+ lifecycle_df = CorruLabEngine.predict_lifecycle(safe_load_kg, humidity)
579
+
580
+ # Plotly Chart
581
+ fig_life = px.line(lifecycle_df, x='Days', y='Safe Load (kg)', title=f"Safe Load Degradation @ {humidity}% RH")
582
+ fig_life.add_hline(y=lifecycle_df['Target'][0], line_dash="dash", line_color="red", annotation_text="Safety Threshold (60%)")
583
+ fig_life.update_layout(height=450, hovermode="x unified")
584
+
585
+ st.plotly_chart(fig_life, use_container_width=True)
586
+
587
+ # --- TAB 3: PRODUCTION ---
588
+ with tabs[2]:
589
+ st.markdown("### 🏭 Production Planning & Wastage")
590
+
591
+ # 1. Wastage Calculation (Moved to top for Dashboard access)
592
+ wastage_df = CorruLabEngine.calculate_wastage_per_layer(CARTON_SPEC, FACTORY_CONFIG)
593
+
594
+ # Metrics - Sheet Area (Full Rectangle - factory buys full sheet)
595
+ # Stitch cut-outs are waste, tracked separately
596
+ flap_height_mm = min(l_mm, w_mm) / 2.0 # RSC standard flap
597
+ sheet_area_m2 = ((2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm) * (w_mm + h_mm)) / 1_000_000.0
598
+ stitch_cutout_area_m2 = (CARTON_SPEC.stitch_allowance_mm * 2 * flap_height_mm) / 1_000_000.0
599
+
600
+ # Calculate Total Weight (Net of Box * Qty) - Approximate
601
+ # Better: sum of layer gsm
602
+ total_gsm = sum(l.paper.gsm * (l.flute_profile.factor if l.flute_profile else 1.0) for l in CARTON_SPEC.layers)
603
+ net_weight_box_kg = total_gsm * sheet_area_m2 / 1000.0
604
+ total_batch_weight_ton = (net_weight_box_kg * order_qty) / 1000.0
605
+
606
+ # Run Length (Linear Meters of Board)
607
+ # Assuming board width is fixed by Deckle? No, Corrugator runs full width.
608
+ # The Cut Length is the running direction.
609
+ # Linear Meters = (Sheet Length / 1000) * Qty / Ups
610
+ try:
611
+ avg_ups = wastage_df['ups'].mean()
612
+ except:
613
+ avg_ups = 1
614
+
615
+ running_cut_length_m = (2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm) / 1000.0
616
+ total_linear_meters = (running_cut_length_m * order_qty) / max(1, avg_ups)
617
+
618
+ # Estimated Time (at 150m/min avg)
619
+ est_time_min = total_linear_meters / 150.0
620
+
621
+ # Dashboard Grid
622
+ m1, m2, m3, m4 = st.columns(4)
623
+ with m1:
624
+ st.metric("Total Batch Weight", f"{total_batch_weight_ton:.2f} tons", help="Net Paper Weight")
625
+ with m2:
626
+ st.metric("Linear Meters", f"{total_linear_meters:,.0f} m", help="Total board length at corrugator")
627
+ with m3:
628
+ st.metric("Est. Run Time", f"{est_time_min:.0f} mins", help="Based on 150m/min avg speed")
629
+ with m4:
630
+ st.metric("Avg. Ups", f"{avg_ups:.1f}", help="Average Ups across layers")
631
+
632
+ # --- WASTAGE BREAKDOWN (NEW TRANSPARENCY SECTION) ---
633
+ st.divider()
634
+ st.markdown("#### πŸ“Š Wastage Breakdown")
635
+
636
+ # Calculate detailed wastage
637
+ total_net_kg = net_weight_box_kg * order_qty
638
+
639
+ # Get gross from breakdown - using same corrected sheet area
640
+ sheet_area = sheet_area_m2 # Already calculated above with stitch cut-out correction
641
+
642
+ total_process_waste_kg = 0.0
643
+ total_trim_waste_kg = 0.0
644
+ total_gross_kg = 0.0
645
+
646
+ for i, layer in enumerate(CARTON_SPEC.layers):
647
+ takeup = layer.flute_profile.factor if layer.flute_profile else 1.0
648
+ weight_net_kg = (layer.paper.gsm * takeup * sheet_area / 1000.0) * order_qty
649
+
650
+ row = wastage_df.loc[wastage_df['layer_idx'] == i].iloc[0]
651
+
652
+ # Process waste
653
+ w_after_process = weight_net_kg * (1 + FACTORY_CONFIG.wastage_process_pct/100.0)
654
+ process_waste = w_after_process - weight_net_kg
655
+ total_process_waste_kg += process_waste
656
+
657
+ # Trim waste
658
+ if row['deckle_mm'] > 0 and row['ups'] > 0:
659
+ deckle_util = (row['ups'] * (w_mm + h_mm)) / row['deckle_mm']
660
+ if deckle_util > 0:
661
+ w_gross = w_after_process / deckle_util
662
+ else:
663
+ w_gross = w_after_process
664
+ else:
665
+ w_gross = w_after_process
666
+ deckle_util = 1.0
667
+
668
+ trim_waste = w_gross - w_after_process
669
+ total_trim_waste_kg += trim_waste
670
+ total_gross_kg += w_gross
671
+
672
+ # Calculate stitch cut-out waste
673
+ stitch_cutout_waste_kg = stitch_cutout_area_m2 * total_gsm / 1000.0 * order_qty
674
+
675
+ total_waste_kg = total_process_waste_kg + total_trim_waste_kg + stitch_cutout_waste_kg
676
+ waste_pct = (total_waste_kg / total_net_kg) * 100 if total_net_kg > 0 else 0
677
+
678
+ # Display breakdown
679
+ waste_col1, waste_col2 = st.columns([2, 1])
680
+
681
+ with waste_col1:
682
+ st.markdown(f"""
683
+ | Component | Formula | Amount |
684
+ |-----------|---------|--------|
685
+ | **Paper Purchased** | GSM Γ— Takeup Γ— FullArea Γ— Qty | **{total_net_kg:,.0f} kg** |
686
+ | **- Stitch Cut-outs** | Paper cut above/below stitch | -{stitch_cutout_waste_kg:,.0f} kg |
687
+ | **+ Process Waste** | Paper Γ— {FACTORY_CONFIG.wastage_process_pct}% | +{total_process_waste_kg:,.0f} kg |
688
+ | **+ Trim Waste** | (100% - Deckle Utilization) | +{total_trim_waste_kg:,.0f} kg |
689
+ | **= In Box** | Finished product weight | **{total_net_kg - stitch_cutout_waste_kg:,.0f} kg** |
690
+ """)
691
+
692
+ with waste_col2:
693
+ # Visual gauge
694
+ st.metric("Total Waste", f"{total_waste_kg:,.0f} kg", f"{waste_pct:.1f}%", delta_color="inverse")
695
+ st.caption(f"Stitch: {stitch_cutout_waste_kg:.0f} kg | Process: {total_process_waste_kg:.0f} kg | Trim: {total_trim_waste_kg:.0f} kg")
696
+
697
+ st.info(f"""
698
+ **πŸ’‘ How Waste is Calculated:**
699
+ 1. **Stitch Cut-outs**: Flap areas above/below stitch tab ({CARTON_SPEC.stitch_allowance_mm}mm Γ— 2 Γ— {flap_height_mm}mm) are cut and discarded
700
+ 2. **Process Waste ({FACTORY_CONFIG.wastage_process_pct}%)**: Corrugator running waste, edge tears, startup/stop losses
701
+ 3. **Trim Waste**: When deckle size doesn't divide evenly by sheet width (W+H = {w_mm+h_mm}mm)
702
+ """)
703
+
704
+ st.divider()
705
+
706
+ # Display 2D Visualization for each unique layer
707
+ st.markdown("#### Deckle Utilization Visualizer")
708
+
709
+ # Loop through unique layers in wastage_df
710
+ for idx, row in wastage_df.iterrows():
711
+ l_name = f"{row['layer_type']} Layer {row['layer_idx']+1} ({row['paper']})"
712
+ ups = int(row['ups'])
713
+ deckle = float(row['deckle_mm'])
714
+
715
+ # Calculate Sheet Width used for optimization (Back calculate or assume?)
716
+ # Logic in calculations: ups = int(deckle / sheet_w)
717
+ # We need the sheet width approx.
718
+ # Used Width = Deckle - Trim
719
+ # Sheet Width = Used / Ups
720
+ if ups > 0:
721
+ used_w = deckle - float(row['trim_mm'])
722
+ sheet_w = used_w / ups
723
+ else:
724
+ sheet_w = 0
725
+
726
+ # Calculate sheet length (L + W + L + W + stitch)
727
+ sheet_length = 2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm
728
+ utilization = 100 - float(row['trim_pct'])
729
+
730
+ fig_viz = plot_deckle_visualization(deckle, sheet_w, sheet_length, ups, l_name, utilization)
731
+ st.plotly_chart(fig_viz, use_container_width=True)
732
+
733
+ # Summary Table
734
+ st.markdown("#### Detailed Stats")
735
+ st.dataframe(wastage_df.style.format({
736
+ "trim_pct": "{:.2f}%",
737
+ "trim_mm": "{:.1f}",
738
+ "deckle_mm": "{:.0f}"
739
+ }), use_container_width=True)
740
+
741
+
742
+
743
+ st.divider()
744
+
745
+ st.divider()
746
+
747
+ st.markdown("### πŸ—οΈ Engineering Visualizers")
748
+
749
+ vis_col1, vis_col2 = st.columns([1, 1])
750
+
751
+ # --- ENHANCED 2D NET VISUALIZER ---
752
+ # Dimensions
753
+ L_net = l_mm
754
+ W_net = w_mm
755
+ H_net = h_mm
756
+ S_net = CARTON_SPEC.stitch_allowance_mm
757
+
758
+ # Helper for formatting units
759
+ use_inch = st.session_state.get('unit', 'mm') == 'inch'
760
+ def fmt_dim(val_mm):
761
+ if use_inch:
762
+ return f"{val_mm/25.4:.2f} in"
763
+ return f"{val_mm:.0f} mm"
764
+ def fmt_val(val_mm): # Just number for text labels with space constraint
765
+ if use_inch:
766
+ return f"{val_mm/25.4:.2f}"
767
+ return f"{val_mm:.0f}"
768
+
769
+ # X Coordinates (Length Direction)
770
+ # Flap - Length - Width - Length - Width
771
+ x_coords = [0, S_net, S_net+L_net, S_net+L_net+W_net, S_net+2*L_net+W_net, S_net+2*L_net+2*W_net]
772
+ total_net_w = x_coords[-1]
773
+
774
+ # Y Coordinates (Height Direction)
775
+ # Flap - Height - Flap
776
+ flap_h = min(L_net, W_net) / 2.0 # RSC flap is half of smaller dimension (L or W)
777
+ y_coords = [0, flap_h, flap_h+H_net, 2*flap_h+H_net]
778
+ total_net_h = y_coords[-1]
779
+
780
+ fig_2d = go.Figure()
781
+
782
+ # --- TECHNICAL DIELINE LOGIC ---
783
+ # Green = Cut Lines (Outline)
784
+ # Red Dashed = Crease Lines (Internal Folds)
785
+
786
+ cut_lines_x = []
787
+ cut_lines_y = []
788
+
789
+ crease_lines_x = []
790
+ crease_lines_y = []
791
+
792
+ # Slot Gap (e.g., 5mm) to separate flap edges
793
+ slot_gap = 5.0
794
+
795
+ # --- 1. Outline (Cut Lines) ---
796
+ # We trace the outer boundary:
797
+ # Start Top-Left of Stitch Tab -> Top Edge of Flaps -> Right Edge -> Bottom Edge -> Stitch Tab
798
+
799
+ # Stitch Tab: (0, flap)->(S, flap) [Top] ? No, Stitch usually has angled cut.
800
+ # Let's keep smooth rect for now.
801
+ # Stitch: x=0..S, y=flap..flap+H
802
+
803
+ # Top Edge Path:
804
+ # 1. Stitch Top: (0, flap) -> (S, flap)
805
+ # 2. Panel 1 Top Flap: (S + gap/2, 0) -> (S+L - gap/2, 0)
806
+ # 3. Panel 2 Top Flap: (S+L + gap/2, 0) -> (S+L+W - gap/2, 0)
807
+ # ...
808
+
809
+ # Actually, simpler to draw "Per Panel" outlines.
810
+ # Cut Lines = Top of Flaps + Bottom of Flaps + Side Edges of Flaps + Outer Edges of End Panels.
811
+ # Crease Lines = Flap Hinge (Horizontal) + Panel Hinge (Vertical).
812
+
813
+ # Stitch Tab
814
+ # Cut: Left (0, flap->flap+H), Top (0,flap->S,flap), Bottom (0,flap+H->S,flap+H).
815
+ # Hinge(Red): Right (S, flap->flap+H) attached to Panel 1.
816
+
817
+ # Cut Lines (Green)
818
+ # Stitch Left
819
+ fig_2d.add_trace(go.Scatter(x=[0, 0], y=[flap_h, flap_h+H_net], mode='lines', line=dict(color='green', width=2), showlegend=False))
820
+ # Stitch Top/Bot (Angled usually, but straight here)
821
+ fig_2d.add_trace(go.Scatter(x=[0, S_net], y=[flap_h, flap_h], mode='lines', line=dict(color='green', width=2), showlegend=False))
822
+ fig_2d.add_trace(go.Scatter(x=[0, S_net], y=[flap_h+H_net, flap_h+H_net], mode='lines', line=dict(color='green', width=2), showlegend=False))
823
+
824
+ # Panels Loop
825
+ panels = [
826
+ (S_net, x_coords[2]), # Panel 1 (L)
827
+ (x_coords[2], x_coords[3]), # Panel 2 (W)
828
+ (x_coords[3], x_coords[4]), # Panel 3 (L)
829
+ (x_coords[4], x_coords[5]) # Panel 4 (W)
830
+ ]
831
+
832
+ for idx, (x_start, x_end) in enumerate(panels):
833
+ # Vertical Hinge (Start) - Red Dashed
834
+ # If it's the first panel, it's the Stitch Hinge.
835
+ # If it's later, it's shared with prev panel.
836
+ fig_2d.add_trace(go.Scatter(x=[x_start, x_start], y=[flap_h, flap_h+H_net], mode='lines', line=dict(color='red', width=1, dash='dash'), showlegend=False))
837
+
838
+ # Horizontal Hinges (Top & Bottom) - Red Dashed
839
+ # Flap Fold Lines
840
+ fig_2d.add_trace(go.Scatter(x=[x_start, x_end], y=[flap_h, flap_h], mode='lines', line=dict(color='red', width=1, dash='dash'), showlegend=False))
841
+ fig_2d.add_trace(go.Scatter(x=[x_start, x_end], y=[flap_h+H_net, flap_h+H_net], mode='lines', line=dict(color='red', width=1, dash='dash'), showlegend=False))
842
+
843
+ # --- Flaps (Green Cuts) ---
844
+ # Top Flap
845
+ # Left Edge (Cut) - with gap
846
+ # But adjacent flaps share a slot.
847
+ # So "x_start" -> "x_start + slot_gap/2" is void?
848
+ # Standard: Standard slot is cut centered on the crease line.
849
+ # So Flap Left Edge starts at x_start + slot/2.
850
+
851
+ # Top Flap Path:
852
+ # (x_start + gap/2, flap) -> (x_start + gap/2, 0)
853
+ # (x_start + gap/2, 0) -> (x_end - gap/2, 0)
854
+ # (x_end - gap/2, 0) -> (x_end - gap/2, flap)
855
+
856
+ gap_offset = slot_gap / 2.0
857
+
858
+ t_x = [x_start + gap_offset, x_start + gap_offset, x_end - gap_offset, x_end - gap_offset]
859
+ t_y = [flap_h, 0, 0, flap_h]
860
+ fig_2d.add_trace(go.Scatter(x=t_x, y=t_y, mode='lines', line=dict(color='green', width=2), showlegend=False))
861
+
862
+ # Bottom Flap Path
863
+ b_x = [x_start + gap_offset, x_start + gap_offset, x_end - gap_offset, x_end - gap_offset]
864
+ b_y = [flap_h + H_net, total_net_h, total_net_h, flap_h + H_net]
865
+ fig_2d.add_trace(go.Scatter(x=b_x, y=b_y, mode='lines', line=dict(color='green', width=2), showlegend=False))
866
+
867
+ # Final Vertical Line (End of Panel 4) - Green Cut
868
+ last_x = x_coords[5]
869
+ fig_2d.add_trace(go.Scatter(x=[last_x, last_x], y=[flap_h, flap_h+H_net], mode='lines', line=dict(color='green', width=2), showlegend=False))
870
+
871
+ # --- DIMENSIONS ---
872
+ # Add Arrow Annotations for L, W, H
873
+
874
+ # 1. Length (Panel 1) - Inside Panel
875
+ p1_center = (panels[0][0] + panels[0][1]) / 2
876
+
877
+ # Better: Use 'shapes' for arrows or just text?
878
+ # Let's simple text.
879
+ fig_2d.add_annotation(x=p1_center, y=flap_h + H_net/2, text=f"L={fmt_val(L_net)}", showarrow=False, font=dict(size=12, color="black"))
880
+
881
+ # 2. Width (Panel 2)
882
+ p2_center = (panels[1][0] + panels[1][1]) / 2
883
+ fig_2d.add_annotation(x=p2_center, y=flap_h + H_net/2, text=f"W={fmt_val(W_net)}", showarrow=False, font=dict(size=12, color="black"))
884
+
885
+ # 3. Flap Height (W/2)
886
+ # Vertical Arrow on Flap 2
887
+ fig_2d.add_annotation(x=p2_center, y=flap_h/2, text=f"Flap={fmt_val(flap_h)}", showarrow=False, font=dict(size=10, color="gray"))
888
+
889
+ # 4. Total Height (H)
890
+ # Arrow on side
891
+ # x= -20
892
+ # y= flap -> flap+H
893
+ fig_2d.add_annotation(
894
+ x=-30, y=flap_h + H_net/2,
895
+ text=f"H={fmt_val(H_net)}", showarrow=False, textangle=-90
896
+ )
897
+ # Vertical Line for H dimension
898
+ fig_2d.add_shape(type="line", x0=-10, y0=flap_h, x1=-10, y1=flap_h+H_net, line=dict(color="black", width=1))
899
+ fig_2d.add_shape(type="line", x0=-15, y0=flap_h, x1=-5, y1=flap_h, line=dict(color="black", width=1)) # Tick
900
+ fig_2d.add_shape(type="line", x0=-15, y0=flap_h+H_net, x1=-5, y1=flap_h+H_net, line=dict(color="black", width=1)) # Tick
901
+
902
+ # Styling
903
+ padding_x = total_net_w * 0.1
904
+ padding_y = total_net_h * 0.1
905
+
906
+ fig_2d.update_layout(
907
+ title=f"2D Production Net ({fmt_val(total_net_w)} x {fmt_val(total_net_h)} {unit_label( '', unit_system).split('(')[1][:-1]})",
908
+ # Hacky split to get 'mm' or 'inch'. Better: Use my 'unit_label' or just 'unit'.
909
+ # Actually I defined 'unit_label' helper earlier?
910
+ # Let's use simpler: 'mm' if unit=='mm' else 'inch'
911
+ # But wait, local scope issues? No.
912
+ xaxis=dict(showgrid=False, showticklabels=False, visible=False, range=[-padding_x, total_net_w + padding_x]),
913
+ yaxis=dict(showgrid=False, showticklabels=False, visible=False, scaleanchor="x", scaleratio=1, range=[total_net_h + padding_y, -padding_y]),
914
+ height=350,
915
+ margin=dict(l=10, r=10, t=30, b=10),
916
+ plot_bgcolor='white',
917
+ )
918
+
919
+
920
+ # --- SIMPLIFIED 3D VISUALIZATION ---
921
+ st.markdown("#### 🧊 3D Box Visualization")
922
+
923
+ # Simple toggle between flat net and folded box
924
+ view_mode = st.radio("View Mode", ["πŸ“„ Flat Net", "πŸ“¦ Folded Box"], horizontal=True, help="Toggle between flat production sheet and assembled box")
925
+
926
+ import numpy as np
927
+
928
+ # Color scheme
929
+ COLOR_STITCH = "#2962FF" # Blue for stitch tab
930
+ COLOR_PANEL_L = "#C19A6B" # Brown for Length panels (1, 3)
931
+ COLOR_PANEL_W = "#A67C52" # Slightly different brown for Width panels (2, 4)
932
+ COLOR_FLAP = "#D4A574" # Lighter brown for flaps
933
+
934
+ fig_3d = go.Figure()
935
+
936
+ # Helper: Create quad mesh
937
+ def add_quad(corners, color, name):
938
+ """Add a quadrilateral mesh from 4 corner points [(x,y,z), ...]"""
939
+ x = [c[0] for c in corners]
940
+ y = [c[1] for c in corners]
941
+ z = [c[2] for c in corners]
942
+
943
+ fig_3d.add_trace(go.Mesh3d(
944
+ x=x, y=y, z=z,
945
+ i=[0, 0], j=[1, 2], k=[2, 3],
946
+ color=color, opacity=0.95, name=name,
947
+ flatshading=True,
948
+ lighting=dict(ambient=0.7, diffuse=0.8, roughness=0.3, specular=0.2)
949
+ ))
950
+
951
+ # Wireframe
952
+ wx = x + [x[0]]
953
+ wy = y + [y[0]]
954
+ wz = z + [z[0]]
955
+ fig_3d.add_trace(go.Scatter3d(
956
+ x=wx, y=wy, z=wz, mode='lines',
957
+ line=dict(color='#333', width=2), showlegend=False
958
+ ))
959
+
960
+ unit_label = "in" if use_inch else "mm"
961
+
962
+ if view_mode == "πŸ“„ Flat Net":
963
+ # === FLAT NET VIEW ===
964
+ # Lay the entire net flat on XY plane (Z=0)
965
+ # Same layout as 2D: Stitch | P1(L) | P2(W) | P3(L) | P4(W)
966
+ # Y direction: Bottom Flap | Main Body (H) | Top Flap
967
+
968
+ z = 0 # Flat on table
969
+
970
+ # Panel X positions (same as 2D)
971
+ stitch_x = (0, S_net)
972
+ p1_x = (S_net, S_net + L_net)
973
+ p2_x = (S_net + L_net, S_net + L_net + W_net)
974
+ p3_x = (S_net + L_net + W_net, S_net + 2*L_net + W_net)
975
+ p4_x = (S_net + 2*L_net + W_net, total_net_w)
976
+
977
+ # Y positions
978
+ bot_flap_y = (0, flap_h)
979
+ main_y = (flap_h, flap_h + H_net)
980
+ top_flap_y = (flap_h + H_net, total_net_h)
981
+
982
+ # Stitch Tab (main body only, no flaps)
983
+ add_quad([
984
+ (stitch_x[0], main_y[0], z), (stitch_x[1], main_y[0], z),
985
+ (stitch_x[1], main_y[1], z), (stitch_x[0], main_y[1], z)
986
+ ], COLOR_STITCH, "Stitch")
987
+
988
+ # Panel 1 (L) + Flaps
989
+ add_quad([(p1_x[0], main_y[0], z), (p1_x[1], main_y[0], z), (p1_x[1], main_y[1], z), (p1_x[0], main_y[1], z)], COLOR_PANEL_L, "Panel 1 (L)")
990
+ add_quad([(p1_x[0], bot_flap_y[0], z), (p1_x[1], bot_flap_y[0], z), (p1_x[1], bot_flap_y[1], z), (p1_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P1 Bot Flap")
991
+ add_quad([(p1_x[0], top_flap_y[0], z), (p1_x[1], top_flap_y[0], z), (p1_x[1], top_flap_y[1], z), (p1_x[0], top_flap_y[1], z)], COLOR_FLAP, "P1 Top Flap")
992
+
993
+ # Panel 2 (W) + Flaps
994
+ add_quad([(p2_x[0], main_y[0], z), (p2_x[1], main_y[0], z), (p2_x[1], main_y[1], z), (p2_x[0], main_y[1], z)], COLOR_PANEL_W, "Panel 2 (W)")
995
+ add_quad([(p2_x[0], bot_flap_y[0], z), (p2_x[1], bot_flap_y[0], z), (p2_x[1], bot_flap_y[1], z), (p2_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P2 Bot Flap")
996
+ add_quad([(p2_x[0], top_flap_y[0], z), (p2_x[1], top_flap_y[0], z), (p2_x[1], top_flap_y[1], z), (p2_x[0], top_flap_y[1], z)], COLOR_FLAP, "P2 Top Flap")
997
+
998
+ # Panel 3 (L) + Flaps
999
+ add_quad([(p3_x[0], main_y[0], z), (p3_x[1], main_y[0], z), (p3_x[1], main_y[1], z), (p3_x[0], main_y[1], z)], COLOR_PANEL_L, "Panel 3 (L)")
1000
+ add_quad([(p3_x[0], bot_flap_y[0], z), (p3_x[1], bot_flap_y[0], z), (p3_x[1], bot_flap_y[1], z), (p3_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P3 Bot Flap")
1001
+ add_quad([(p3_x[0], top_flap_y[0], z), (p3_x[1], top_flap_y[0], z), (p3_x[1], top_flap_y[1], z), (p3_x[0], top_flap_y[1], z)], COLOR_FLAP, "P3 Top Flap")
1002
+
1003
+ # Panel 4 (W) + Flaps
1004
+ add_quad([(p4_x[0], main_y[0], z), (p4_x[1], main_y[0], z), (p4_x[1], main_y[1], z), (p4_x[0], main_y[1], z)], COLOR_PANEL_W, "Panel 4 (W)")
1005
+ add_quad([(p4_x[0], bot_flap_y[0], z), (p4_x[1], bot_flap_y[0], z), (p4_x[1], bot_flap_y[1], z), (p4_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P4 Bot Flap")
1006
+ add_quad([(p4_x[0], top_flap_y[0], z), (p4_x[1], top_flap_y[0], z), (p4_x[1], top_flap_y[1], z), (p4_x[0], top_flap_y[1], z)], COLOR_FLAP, "P4 Top Flap")
1007
+
1008
+ # Dimension labels
1009
+ fig_3d.add_trace(go.Scatter3d(
1010
+ x=[total_net_w / 2], y=[total_net_h + 20], z=[5],
1011
+ mode='text', text=[f"Sheet: {fmt_val(total_net_w)} Γ— {fmt_val(total_net_h)} {unit_label}"],
1012
+ textfont=dict(size=12, color='#1976D2'), showlegend=False
1013
+ ))
1014
+ fig_3d.add_trace(go.Scatter3d(
1015
+ x=[(p1_x[0] + p1_x[1]) / 2], y=[flap_h + H_net / 2], z=[5],
1016
+ mode='text', text=[f"L={fmt_val(L_net)}"],
1017
+ textfont=dict(size=11, color='#333'), showlegend=False
1018
+ ))
1019
+ fig_3d.add_trace(go.Scatter3d(
1020
+ x=[(p2_x[0] + p2_x[1]) / 2], y=[flap_h + H_net / 2], z=[5],
1021
+ mode='text', text=[f"W={fmt_val(W_net)}"],
1022
+ textfont=dict(size=11, color='#333'), showlegend=False
1023
+ ))
1024
+ fig_3d.add_trace(go.Scatter3d(
1025
+ x=[(p2_x[0] + p2_x[1]) / 2], y=[flap_h / 2], z=[5],
1026
+ mode='text', text=[f"Flap={fmt_val(flap_h)}"],
1027
+ textfont=dict(size=10, color='#666'), showlegend=False
1028
+ ))
1029
+
1030
+ # Center the view on the net
1031
+ center_x = total_net_w / 2
1032
+ center_y = total_net_h / 2
1033
+
1034
+ fig_3d.update_layout(
1035
+ title=f"3D Flat Net ({fmt_val(total_net_w)} Γ— {fmt_val(total_net_h)} {unit_label})",
1036
+ scene=dict(
1037
+ xaxis=dict(title=f'X ({unit_label})', range=[-total_net_w*0.1, total_net_w * 1.15]),
1038
+ yaxis=dict(title=f'Y ({unit_label})', range=[-total_net_h*0.1, total_net_h * 1.2]),
1039
+ zaxis=dict(title='Z', range=[0, 30], showticklabels=False),
1040
+ aspectmode='auto', # Auto fit to container
1041
+ camera=dict(
1042
+ eye=dict(x=0.0, y=0.0, z=2.5), # Looking straight down, auto mode handles zoom
1043
+ center=dict(x=0, y=0, z=0),
1044
+ up=dict(x=0, y=1, z=0)
1045
+ )
1046
+ ),
1047
+ margin=dict(l=0, r=0, t=40, b=0),
1048
+ height=500
1049
+ )
1050
+
1051
+ else:
1052
+ # === FOLDED BOX VIEW ===
1053
+ # Show assembled RSC box
1054
+ # Coordinate system: X=Width, Y=Height, Z=Length (depth)
1055
+
1056
+ # Box dimensions
1057
+ W = W_net # Width (X)
1058
+ H = H_net # Height (Y)
1059
+ L = L_net # Length/Depth (Z)
1060
+
1061
+ # Panel 2 - Front face (Z=0)
1062
+ add_quad([(0, 0, 0), (W, 0, 0), (W, H, 0), (0, H, 0)], COLOR_PANEL_W, "Front (P2)")
1063
+
1064
+ # Panel 4 - Back face (Z=L)
1065
+ add_quad([(0, 0, L), (W, 0, L), (W, H, L), (0, H, L)], COLOR_PANEL_W, "Back (P4)")
1066
+
1067
+ # Panel 1 - Left face (X=0)
1068
+ add_quad([(0, 0, 0), (0, 0, L), (0, H, L), (0, H, 0)], COLOR_PANEL_L, "Left (P1)")
1069
+
1070
+ # Panel 3 - Right face (X=W)
1071
+ add_quad([(W, 0, 0), (W, 0, L), (W, H, L), (W, H, 0)], COLOR_PANEL_L, "Right (P3)")
1072
+
1073
+ # Bottom flaps (folded in, Y=0)
1074
+ # Minor flaps (from P2 and P4) - partial overlap
1075
+ add_quad([(0, 0, 0), (W, 0, 0), (W, 0, flap_h), (0, 0, flap_h)], COLOR_FLAP, "P2 Bot Flap")
1076
+ add_quad([(0, 0, L-flap_h), (W, 0, L-flap_h), (W, 0, L), (0, 0, L)], COLOR_FLAP, "P4 Bot Flap")
1077
+ # Major flaps (from P1 and P3) - cover the opening
1078
+ add_quad([(0, 0, flap_h), (0, 0, L-flap_h), (flap_h, 0, L-flap_h), (flap_h, 0, flap_h)], COLOR_FLAP, "P1 Bot Flap")
1079
+ add_quad([(W-flap_h, 0, flap_h), (W-flap_h, 0, L-flap_h), (W, 0, L-flap_h), (W, 0, flap_h)], COLOR_FLAP, "P3 Bot Flap")
1080
+
1081
+ # Top flaps (folded in, Y=H)
1082
+ add_quad([(0, H, 0), (W, H, 0), (W, H, flap_h), (0, H, flap_h)], COLOR_FLAP, "P2 Top Flap")
1083
+ add_quad([(0, H, L-flap_h), (W, H, L-flap_h), (W, H, L), (0, H, L)], COLOR_FLAP, "P4 Top Flap")
1084
+ add_quad([(0, H, flap_h), (0, H, L-flap_h), (flap_h, H, L-flap_h), (flap_h, H, flap_h)], COLOR_FLAP, "P1 Top Flap")
1085
+ add_quad([(W-flap_h, H, flap_h), (W-flap_h, H, L-flap_h), (W, H, L-flap_h), (W, H, flap_h)], COLOR_FLAP, "P3 Top Flap")
1086
+
1087
+ # Stitch tab (attached to back of P1, slightly offset)
1088
+ add_quad([(-2, 0, L-2), (-2, 0, L), (-2, H, L), (-2, H, L-2)], COLOR_STITCH, "Stitch Tab")
1089
+
1090
+ # Dimension labels
1091
+ fig_3d.add_trace(go.Scatter3d(
1092
+ x=[W/2], y=[-30], z=[L/2],
1093
+ mode='text', text=[f"Box: {fmt_val(L)}Γ—{fmt_val(W)}Γ—{fmt_val(H)} {unit_label}"],
1094
+ textfont=dict(size=12, color='#333'), showlegend=False
1095
+ ))
1096
+ # Width label
1097
+ fig_3d.add_trace(go.Scatter3d(
1098
+ x=[W/2], y=[H/2], z=[-15],
1099
+ mode='text', text=[f"W={fmt_val(W)}"],
1100
+ textfont=dict(size=11, color='#333'), showlegend=False
1101
+ ))
1102
+ # Height label
1103
+ fig_3d.add_trace(go.Scatter3d(
1104
+ x=[-20], y=[H/2], z=[L/2],
1105
+ mode='text', text=[f"H={fmt_val(H)}"],
1106
+ textfont=dict(size=11, color='#333'), showlegend=False
1107
+ ))
1108
+ # Length label
1109
+ fig_3d.add_trace(go.Scatter3d(
1110
+ x=[W+15], y=[H/2], z=[L/2],
1111
+ mode='text', text=[f"L={fmt_val(L)}"],
1112
+ textfont=dict(size=11, color='#333'), showlegend=False
1113
+ ))
1114
+
1115
+ max_dim = max(W, H, L) * 1.5
1116
+ fig_3d.update_layout(
1117
+ title=f"3D Folded Box ({fmt_val(L)}Γ—{fmt_val(W)}Γ—{fmt_val(H)} {unit_label})",
1118
+ scene=dict(
1119
+ xaxis=dict(title=f'Width ({unit_label})', range=[-50, max_dim]),
1120
+ yaxis=dict(title=f'Height ({unit_label})', range=[-50, max_dim]),
1121
+ zaxis=dict(title=f'Length ({unit_label})', range=[-20, max_dim]),
1122
+ aspectmode='data',
1123
+ camera=dict(eye=dict(x=1.5, y=1.2, z=1.5), up=dict(x=0, y=1, z=0))
1124
+ ),
1125
+ margin=dict(l=0, r=0, t=40, b=0),
1126
+ height=500
1127
+ )
1128
+
1129
+ with vis_col1: st.plotly_chart(fig_2d, use_container_width=True)
1130
+ with vis_col2: st.plotly_chart(fig_3d, use_container_width=True)
1131
+
1132
+
1133
+ # --- TAB 4: FINANCIALS ---
1134
+ with tabs[3]:
1135
+ st.markdown("### πŸ’° Financial Analysis & Costing")
1136
+
1137
+ # order_qty is now global in sidebar
1138
+ cost_res = CorruLabEngine.calculate_granular_cost(CARTON_SPEC, FACTORY_CONFIG, wastage_df, order_qty)
1139
+
1140
+ # === SECTION 1: KEY METRICS DASHBOARD ===
1141
+ st.markdown("#### πŸ“Š Order Summary")
1142
+
1143
+ total_revenue = cost_res['price'] * order_qty
1144
+ total_cost = cost_res['factory_cost'] * order_qty
1145
+ total_profit = total_revenue - total_cost
1146
+ profit_margin_pct = (total_profit / total_revenue) * 100 if total_revenue > 0 else 0
1147
+
1148
+ metric_cols = st.columns(5)
1149
+ with metric_cols[0]:
1150
+ st.metric("Order Quantity", f"{order_qty:,} boxes",
1151
+ help="Number of boxes in this order (set in sidebar)")
1152
+ with metric_cols[1]:
1153
+ st.metric("Price / Box", f"Rs {cost_res['price']:.2f}",
1154
+ help=f"Factory Cost + {FACTORY_CONFIG.margin_pct}% Margin = Rs {cost_res['factory_cost']:.2f} Γ— 1.{int(FACTORY_CONFIG.margin_pct):02d}")
1155
+ with metric_cols[2]:
1156
+ st.metric("Total Revenue", f"Rs {total_revenue:,.0f}",
1157
+ help=f"Price Γ— Quantity = Rs {cost_res['price']:.2f} Γ— {order_qty:,}")
1158
+ with metric_cols[3]:
1159
+ st.metric("Total Profit", f"Rs {total_profit:,.0f}", f"{profit_margin_pct:.1f}%",
1160
+ help=f"Revenue - Cost = Rs {total_revenue:,.0f} - Rs {total_cost:,.0f}")
1161
+ with metric_cols[4]:
1162
+ st.metric("Cost / Box", f"Rs {cost_res['factory_cost']:.2f}",
1163
+ help="Sum of: Paper + Waste + Conversion + Value-Add + Setup")
1164
+
1165
+ st.divider()
1166
+
1167
+ # === FORMULA REFERENCE ===
1168
+ with st.expander("πŸ“ How Each Cost is Calculated (Click to Expand)", expanded=False):
1169
+ st.markdown(f"""
1170
+ | Cost Component | Formula | Your Values |
1171
+ |----------------|---------|-------------|
1172
+ | **Paper (Net)** | GSM Γ— Takeup Γ— Area Γ— Rate Γ· 1000 | Area = {sheet_area_m2*1e6:,.0f} mmΒ² |
1173
+ | **Waste** | Paper Γ— (Process% + Trim%) | Process = {FACTORY_CONFIG.wastage_process_pct}% |
1174
+ | **Conversion** | Weight Γ— Rate/kg | {cost_res['weight_per_box']*1000:.0f}g Γ— Rs {FACTORY_CONFIG.cost_conversion_per_kg}/kg |
1175
+ | **Setup** | Fixed Cost Γ· Order Qty | Rs {FACTORY_CONFIG.cost_fixed_setup:,.0f} Γ· {order_qty} = Rs {cost_res['setup_cost']:.2f} |
1176
+ | **Margin** | Factory Cost Γ— {FACTORY_CONFIG.margin_pct}% | Rs {cost_res['factory_cost']:.2f} Γ— {FACTORY_CONFIG.margin_pct}% |
1177
+
1178
+ **Value-Add Costs** (only if enabled):
1179
+ - Printing: Rs {FACTORY_CONFIG.cost_printing_per_1000}/1000 + Plate cost/qty
1180
+ - UV: Rs {FACTORY_CONFIG.cost_uv_per_1000}/1000
1181
+ - Lamination: Rs {FACTORY_CONFIG.cost_lamination_per_1000}/1000
1182
+ - Die-cutting: Rs {FACTORY_CONFIG.cost_die_cutting_per_1000}/1000 + Frame cost/qty
1183
+ """)
1184
+
1185
+ st.divider()
1186
+
1187
+ # === SECTION 2: COST BREAKDOWN ===
1188
+ fin_col1, fin_col2 = st.columns([1, 1])
1189
+
1190
+ with fin_col1:
1191
+ st.markdown("#### πŸ“ˆ Cost Breakdown (Per Box)")
1192
+
1193
+ cost_data = [
1194
+ {"Component": "Paper (Net)", "Cost": cost_res['paper_cost_net'], "Category": "Material"},
1195
+ {"Component": "Waste", "Cost": cost_res['trim_and_process_waste_cost'], "Category": "Waste"},
1196
+ {"Component": "Printing", "Cost": cost_res['print_cost'], "Category": "Value Add"},
1197
+ {"Component": "UV/Lam/Die", "Cost": cost_res['uv_cost'] + cost_res['lam_cost'] + cost_res['die_cost'], "Category": "Value Add"},
1198
+ {"Component": "Setup", "Cost": cost_res['setup_cost'], "Category": "Fixed"},
1199
+ ]
1200
+ df_cost = pd.DataFrame(cost_data)
1201
+
1202
+ # Filter out zero-cost items
1203
+ df_cost = df_cost[df_cost['Cost'] > 0.001]
1204
+
1205
+ # Pie Chart
1206
+ fig_pie = px.pie(df_cost, values='Cost', names='Component',
1207
+ title='Cost Distribution',
1208
+ color='Category',
1209
+ color_discrete_map={
1210
+ "Material": "#2E86AB", "Waste": "#E94F37",
1211
+ "Processing": "#F39237", "Value Add": "#96E6A1", "Fixed": "#DDA0DD"
1212
+ })
1213
+ fig_pie.update_traces(textposition='inside', textinfo='percent+label')
1214
+ st.plotly_chart(fig_pie, use_container_width=True)
1215
+
1216
+ with fin_col2:
1217
+ st.markdown("#### πŸ“‰ Cost Waterfall")
1218
+
1219
+ # Waterfall chart - only non-zero items
1220
+ waterfall_data = [
1221
+ {"x": "Paper", "y": cost_res['paper_cost_net'], "type": "relative"},
1222
+ {"x": "Waste", "y": cost_res['trim_and_process_waste_cost'], "type": "relative"},
1223
+ ]
1224
+
1225
+ # Add value-add only if enabled
1226
+ value_add_cost = cost_res['print_cost'] + cost_res['uv_cost'] + cost_res['lam_cost'] + cost_res['die_cost']
1227
+ if value_add_cost > 0:
1228
+ waterfall_data.append({"x": "Value Add", "y": value_add_cost, "type": "relative"})
1229
+
1230
+ waterfall_data.extend([
1231
+ {"x": "Setup", "y": cost_res['setup_cost'], "type": "relative"},
1232
+ {"x": "Factory Cost", "y": cost_res['factory_cost'], "type": "total"},
1233
+ {"x": f"+Margin ({FACTORY_CONFIG.margin_pct}%)", "y": cost_res['price'] - cost_res['factory_cost'], "type": "relative"},
1234
+ {"x": "Price", "y": cost_res['price'], "type": "total"},
1235
+ ])
1236
+
1237
+ fig_waterfall = go.Figure(go.Waterfall(
1238
+ x=[d['x'] for d in waterfall_data],
1239
+ y=[d['y'] for d in waterfall_data],
1240
+ measure=[d['type'] for d in waterfall_data],
1241
+ text=[f"Rs {d['y']:.2f}" for d in waterfall_data],
1242
+ textposition="outside",
1243
+ connector=dict(line=dict(color="rgb(63, 63, 63)")),
1244
+ increasing=dict(marker=dict(color="#E94F37")),
1245
+ decreasing=dict(marker=dict(color="#2E86AB")),
1246
+ totals=dict(marker=dict(color="#45B7D1"))
1247
+ ))
1248
+ fig_waterfall.update_layout(title="Cost Build-up to Price", showlegend=False, height=400)
1249
+ st.plotly_chart(fig_waterfall, use_container_width=True)
1250
+
1251
+ st.divider()
1252
+
1253
+ # === SECTION 3: DETAILED COST TABLE ===
1254
+ st.markdown("#### πŸ“‹ Cost Summary")
1255
+
1256
+ detail_col1, detail_col2 = st.columns([2, 1])
1257
+
1258
+ with detail_col1:
1259
+ # Add per-box and order-total columns
1260
+ df_cost['Per Box (Rs)'] = df_cost['Cost']
1261
+ df_cost['Order Total (Rs)'] = df_cost['Cost'] * order_qty
1262
+ df_cost['% of Cost'] = (df_cost['Cost'] / cost_res['factory_cost'] * 100)
1263
+
1264
+ st.dataframe(
1265
+ df_cost[['Component', 'Category', 'Per Box (Rs)', 'Order Total (Rs)', '% of Cost']].style.format({
1266
+ "Per Box (Rs)": "{:.2f}",
1267
+ "Order Total (Rs)": "{:,.0f}",
1268
+ "% of Cost": "{:.1f}%"
1269
+ }).background_gradient(subset=['% of Cost'], cmap='Reds'),
1270
+ use_container_width=True, hide_index=True
1271
+ )
1272
+
1273
+ with detail_col2:
1274
+ st.markdown("##### πŸ’΅ Profitability Summary")
1275
+ st.markdown(f"""
1276
+ | Metric | Per Box | Order Total |
1277
+ |--------|---------|-------------|
1278
+ | **Revenue** | Rs {cost_res['price']:.2f} | Rs {total_revenue:,.0f} |
1279
+ | **Cost** | Rs {cost_res['factory_cost']:.2f} | Rs {total_cost:,.0f} |
1280
+ | **Profit** | Rs {cost_res['price'] - cost_res['factory_cost']:.2f} | Rs {total_profit:,.0f} |
1281
+ | **Margin** | {profit_margin_pct:.1f}% | {profit_margin_pct:.1f}% |
1282
+ """)
1283
+
1284
+ st.divider()
1285
+
1286
+ # === SECTION 4: QUOTATION SUMMARY ===
1287
+ st.markdown("#### πŸ“ Quotation Summary")
1288
+
1289
+ quote_col1, quote_col2 = st.columns([1, 1])
1290
+
1291
+ with quote_col1:
1292
+ st.markdown(f"""
1293
+ **πŸ“¦ Product:** {ply_type} RSC Box
1294
+ **πŸ“ Dimensions:** {l_mm} Γ— {w_mm} Γ— {h_mm} mm
1295
+ **βš–οΈ Weight:** {cost_res['weight_per_box']*1000:.0f} g per box
1296
+ **πŸ”’ Quantity:** {order_qty:,} boxes
1297
+ """)
1298
+
1299
+ with quote_col2:
1300
+ st.markdown(f"""
1301
+ <div style='background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1302
+ padding: 20px; border-radius: 10px; color: white; text-align: center;'>
1303
+ <h3 style='margin:0; color: white;'>QUOTED PRICE</h3>
1304
+ <h1 style='margin: 10px 0; color: white;'>Rs {cost_res['price']:.2f} / box</h1>
1305
+ <p style='margin:0; opacity: 0.9;'>Order Total: Rs {total_revenue:,.0f}</p>
1306
+ </div>
1307
+ """, unsafe_allow_html=True)
1308
+
1309
+ # --- TAB 5: REPORTS ---
1310
+ with tabs[4]:
1311
+ st.markdown("### πŸ“„ Job Card & Quote")
1312
+
1313
+ st.markdown(f"""
1314
+ <div style='border:1px solid #ccc; padding:20px; background:white;'>
1315
+ <h2>JOB CARD: {quote_ref}</h2>
1316
+ <p><b>Client:</b> {cust_name} | <b>Date:</b> {datetime.now().strftime('%d-%b-%Y')}</p>
1317
+ <hr>
1318
+ <h4>1. Production Specs</h4>
1319
+ <ul>
1320
+ <li><b>Size:</b> {l_mm} x {w_mm} x {h_mm} mm</li>
1321
+ <li><b>Board:</b> {ply_type}</li>
1322
+ <li><b>Net Weight (Finished Boxes):</b> {cost_res['weight_per_box']*order_qty/1000.0:.2f} Tons ({order_qty} Boxes)</li>
1323
+ <li><b>Score Lines:</b> {CARTON_SPEC.stitch_allowance_mm:.1f} | {l_mm} | {w_mm} | {l_mm} | {w_mm}</li>
1324
+ </ul>
1325
+ <h4>2. Material List & Deckles</h4>
1326
+ <table width='100%' border='1' cellspacing='0' cellpadding='5'>
1327
+ <tr><th>Layer</th><th>Grade</th><th>GSM</th><th>Deckle (mm)</th><th>Burst (kPa)</th><th>RCT-CD (kgf)</th></tr>
1328
+ {''.join([f"<tr><td>{l.layer_type}</td><td>{l.paper.code}</td><td>{l.paper.gsm}</td><td>{l.deckle_mm}</td><td>{l.paper.bursting_strength_kpa}</td><td>{l.paper.rct_cd_N:.1f}</td></tr>" for l in CARTON_SPEC.layers])}
1329
+ </table>
1330
+ </div>
1331
+ """, unsafe_allow_html=True)
1332
+
1333
+ st.markdown("### πŸ“¦ Material Requisition (Store Demand)")
1334
+
1335
+ # Enhanced Per-Layer Breakdown
1336
+ st.markdown("#### Layer-by-Layer Calculation")
1337
+
1338
+ layer_breakdown_data = []
1339
+ # Sheet area - full rectangle (stitch cut-out is waste, not subtracted)
1340
+ flap_h_calc = min(l_mm, w_mm) / 2.0
1341
+ sheet_area_calc = ((2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm) * (w_mm + h_mm)) / 1_000_000.0
1342
+
1343
+ for i, layer in enumerate(CARTON_SPEC.layers):
1344
+ takeup = layer.flute_profile.factor if layer.flute_profile else 1.0
1345
+ weight_net_per_box = (layer.paper.gsm * takeup * sheet_area_calc / 1000.0)
1346
+ weight_net_total = weight_net_per_box * order_qty
1347
+
1348
+ row = wastage_df.loc[wastage_df['layer_idx'] == i].iloc[0]
1349
+
1350
+ # Process waste
1351
+ w_after_process = weight_net_total * (1 + FACTORY_CONFIG.wastage_process_pct/100.0)
1352
+
1353
+ # Trim waste
1354
+ if row['deckle_mm'] > 0 and row['ups'] > 0:
1355
+ deckle_util = (row['ups'] * (w_mm + h_mm)) / row['deckle_mm']
1356
+ deckle_util_pct = deckle_util * 100
1357
+ w_gross = w_after_process / deckle_util if deckle_util > 0 else w_after_process
1358
+ else:
1359
+ deckle_util_pct = 100.0
1360
+ w_gross = w_after_process
1361
+
1362
+ layer_breakdown_data.append({
1363
+ "Layer": f"{layer.layer_type} ({layer.paper.code})",
1364
+ "GSM": layer.paper.gsm,
1365
+ "Takeup": takeup,
1366
+ "Net (kg)": weight_net_total,
1367
+ f"+Process ({FACTORY_CONFIG.wastage_process_pct}%)": w_after_process - weight_net_total,
1368
+ "Deckle Util (%)": deckle_util_pct,
1369
+ "+Trim (kg)": w_gross - w_after_process,
1370
+ "Gross (kg)": w_gross
1371
+ })
1372
+
1373
+ df_layers = pd.DataFrame(layer_breakdown_data)
1374
+ st.dataframe(
1375
+ df_layers.style.format({
1376
+ "Net (kg)": "{:.1f}",
1377
+ f"+Process ({FACTORY_CONFIG.wastage_process_pct}%)": "{:+.1f}",
1378
+ "Deckle Util (%)": "{:.1f}%",
1379
+ "+Trim (kg)": "{:+.1f}",
1380
+ "Gross (kg)": "{:.1f}",
1381
+ "Takeup": "{:.2f}"
1382
+ }),
1383
+ use_container_width=True
1384
+ )
1385
+
1386
+ # Summary row
1387
+ total_net = df_layers["Net (kg)"].sum()
1388
+ total_process = df_layers[f"+Process ({FACTORY_CONFIG.wastage_process_pct}%)"].sum()
1389
+ total_trim = df_layers["+Trim (kg)"].sum()
1390
+ total_gross = df_layers["Gross (kg)"].sum()
1391
+
1392
+ st.markdown(f"""
1393
+ **Summary:**
1394
+ | Net Weight | + Process Waste | + Trim Waste | = Gross Requirement |
1395
+ |------------|-----------------|--------------|---------------------|
1396
+ | {total_net:,.0f} kg | +{total_process:,.0f} kg | +{total_trim:,.0f} kg | **{total_gross:,.0f} kg** |
1397
+ """)
1398
+
1399
+ st.info(f"""
1400
+ **πŸ“ Formula Applied:**
1401
+ `Gross = (Net Γ— 1.{int(FACTORY_CONFIG.wastage_process_pct):02d}) Γ· Deckle_Utilization`
1402
+ Where Deckle_Utilization = (Ups Γ— SheetWidth) Γ· ReelSize
1403
+ """)