Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- src/calculations.py +380 -0
- src/config.py +27 -0
- src/data_loader.py +119 -0
- src/models.py +63 -0
- 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 |
+
""")
|