diff --git a/app/estimation.py b/app/estimation.py index 95d586c..3584ed2 100644 --- a/app/estimation.py +++ b/app/estimation.py @@ -1,4 +1,5 @@ """Job cost and time estimation from STL geometry.""" +import math import os # g/cm³ per material @@ -13,29 +14,46 @@ _DENSITY: dict[str, float] = { } _DEFAULT_DENSITY = 1.24 +# Volume (cm³) at which MATERIAL_FACTOR applies exactly. +# Calibrated to Bambu Lab Studio: 26 cm³ lid → 50 % factor → 16.2 g (actual 16.24 g). +_REF_VOLUME = 25.0 + +# Factor change per decade of volume. +# Each 10× increase in volume → factor drops by this amount (models get fluffier). +# Each 10× decrease → factor rises by this amount (models get denser). +# 0.15 gives: 1 cm³ → +0.21, 250 cm³ → −0.15 relative to base. +_SIZE_SLOPE = 0.15 + + +def _effective_factor(volume_ccm: float, base: float) -> float: + """Scale material factor by model size using log-linear interpolation.""" + factor = base - math.log10(volume_ccm / _REF_VOLUME) * _SIZE_SLOPE + return round(max(0.25, min(0.85, factor)), 3) + def estimate(volume_ccm: float, material: str | None) -> dict: """ Returns a cost/time estimate dict for one job. MATERIAL_FACTOR (env, default 0.50): - Fraction of model volume that becomes actual filament. - '20 % infill' in the slicer does NOT mean 20 % of volume is printed — - walls, top/bottom solid layers, and shells are 100 % solid and typically - dominate. Empirically ~50 % of model volume is printed for typical FDM - parts (validated against Bambu Lab Studio output). + Base fraction of model volume that becomes filament, calibrated at + 25 cm³. Scaled up for small models (wall-dominated) and down for large + models (infill-dominated). Approximate scale: + ~1 cm³ → ×1.4 (tiny clip, almost all walls) + ~25 cm³ → ×1.0 (reference — validated against slicer) + ~250 cm³ → ×0.7 (large box, infill dominates) PRINT_SPEED_G_PER_HOUR (env, default 32): - Average material throughput in g/h. ~32 g/h matches Bambu Lab printers - at normal quality; slower printers (~20 g/h) can be set here. + ~32 g/h matches Bambu Lab at normal quality; use ~20 for slower printers. """ - vat_rate = float(os.environ.get("VAT_RATE", 19)) - price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15)) - material_factor = float(os.environ.get("MATERIAL_FACTOR", 0.50)) - print_speed = float(os.environ.get("PRINT_SPEED_G_PER_HOUR", 32)) - density = _DENSITY.get(material or "", _DEFAULT_DENSITY) + vat_rate = float(os.environ.get("VAT_RATE", 19)) + price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15)) + base_factor = float(os.environ.get("MATERIAL_FACTOR", 0.50)) + print_speed = float(os.environ.get("PRINT_SPEED_G_PER_HOUR", 32)) + density = _DENSITY.get(material or "", _DEFAULT_DENSITY) - material_ccm = volume_ccm * material_factor + eff_factor = _effective_factor(volume_ccm, base_factor) + material_ccm = volume_ccm * eff_factor weight_g = material_ccm * density print_minutes = round((weight_g / print_speed) * 60) @@ -45,14 +63,14 @@ def estimate(volume_ccm: float, material: str | None) -> dict: gross_price = net_price * (1 + vat_rate / 100) return { - "material_ccm": round(material_ccm, 2), - "weight_g": round(weight_g, 1), - "print_minutes": print_minutes, - "filament_cost": round(filament_cost, 2), - "net_price": round(net_price, 2), - "gross_price": round(gross_price, 2), - "vat_rate": vat_rate, - "price_per_kg": price_per_kg, - "material_factor": material_factor, - "print_speed": print_speed, + "material_ccm": round(material_ccm, 2), + "weight_g": round(weight_g, 1), + "print_minutes": print_minutes, + "filament_cost": round(filament_cost, 2), + "net_price": round(net_price, 2), + "gross_price": round(gross_price, 2), + "vat_rate": vat_rate, + "price_per_kg": price_per_kg, + "material_factor": eff_factor, + "print_speed": print_speed, }