Files
filam3d/backend/app/services/price_engine.py
2026-03-22 21:47:30 +03:00

275 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import math
from dataclasses import dataclass
from app.services.file_parser import FileInfo
logger = logging.getLogger("app.services.price_engine")
TIME_RATE_PER_HOUR = 200.0 # руб/час
SETUP_TIME_MIN = 10.0 # подготовка стола, прогрев
POST_PROCESSING_COSTS = {
"sanding": 300.0,
"painting": 500.0,
"threading": 200.0,
"acetone_smoothing": 400.0,
}
MULTICOLOR_SURCHARGE_PERCENT = 30
QUANTITY_DISCOUNTS = [
(1, 0),
(2, 5),
(6, 10),
(21, 15),
(101, 20),
]
# ─── Slicer-like defaults (Bambu Lab / OrcaSlicer profile) ───
WALL_COUNT = 3 # количество стенок (периметров)
LINE_WIDTH_MM = 0.4 # ширина линии
TOP_SOLID_LAYERS = 4 # верхние сплошные слои
BOTTOM_SOLID_LAYERS = 4 # нижние сплошные слои
# Скорости печати (мм/с) — Bambu Lab X1C типичный профиль
SPEED_OUTER_WALL = 80
SPEED_INNER_WALL = 120
SPEED_INFILL = 200
SPEED_TOP_BOTTOM = 80
SPEED_TRAVEL = 300
SPEED_FIRST_LAYER = 50
# Коэффициент учёта retraction, acceleration, non-print moves
# Bambu быстрый, но всё равно ~25-35% времени — непечатные перемещения
NON_PRINT_OVERHEAD = 1.30
@dataclass
class PriceResult:
weight_grams: float
material_cost_rub: float
print_time_hours: float
time_cost_rub: float
post_processing_cost_rub: float
subtotal_rub: float
quantity: int
quantity_discount_percent: int
total_rub: float
estimated_days: int
def get_quantity_discount(quantity: int) -> int:
discount = 0
for min_qty, disc in QUANTITY_DISCOUNTS:
if quantity >= min_qty:
discount = disc
return discount
def _estimate_weight(file_info: FileInfo, density_g_cm3: float,
infill_percent: int, layer_height_mm: float) -> float:
"""
Estimate weight using a slicer-like model:
- Walls: perimeter_length * wall_count * line_width * z_height * density
- Top/bottom: footprint_area * solid_layers * layer_height * density
- Infill: remaining interior volume * infill% * density
"""
bbox = file_info.bounding_box_mm
x = bbox.get("x", 10.0)
y = bbox.get("y", 10.0)
z = bbox.get("z", 10.0)
volume_mm3 = file_info.volume_cm3 * 1000.0
surface_mm2 = file_info.surface_area_cm2 * 100.0
# Approximate the perimeter length per layer from surface area:
# surface_area ≈ perimeter_per_layer * z + 2 * footprint
# So perimeter_per_layer ≈ (surface_area - 2 * footprint) / z
footprint_mm2 = x * y * 0.65 # ~65% fill of bounding box for typical parts
# Clamp: footprint can't be more than half of surface
footprint_mm2 = min(footprint_mm2, surface_mm2 * 0.4)
perimeter_per_layer_mm = max((surface_mm2 - 2 * footprint_mm2) / max(z, 1.0), 10.0)
num_layers = max(z / layer_height_mm, 1)
# Wall volume
wall_thickness_total = WALL_COUNT * LINE_WIDTH_MM
wall_volume_mm3 = perimeter_per_layer_mm * wall_thickness_total * layer_height_mm * num_layers
logger.debug("Walls: perimeter=%.1f mm/layer, thickness=%.1f mm, volume=%.1f mm3",
perimeter_per_layer_mm, wall_thickness_total, wall_volume_mm3)
# Top + bottom solid layers volume (use interior footprint, excluding walls)
solid_layers = TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS
solid_volume_mm3 = footprint_mm2 * layer_height_mm * solid_layers
logger.debug("Solid top/bottom: footprint=%.1f mm2, layers=%d, volume=%.1f mm3",
footprint_mm2, solid_layers, solid_volume_mm3)
# Shell = walls + top/bottom. Cap at 75% of volume so infill always matters.
shell_volume_mm3 = wall_volume_mm3 + solid_volume_mm3
max_shell = volume_mm3 * 0.75
if shell_volume_mm3 > max_shell:
scale = max_shell / shell_volume_mm3
wall_volume_mm3 *= scale
solid_volume_mm3 *= scale
shell_volume_mm3 = max_shell
logger.debug("Shell capped: scale=%.2f, shell=%.1f mm3", scale, shell_volume_mm3)
# Interior volume for infill (total volume minus shell)
interior_volume_mm3 = volume_mm3 - shell_volume_mm3
infill_volume_mm3 = interior_volume_mm3 * (infill_percent / 100.0)
logger.debug("Infill: interior=%.1f mm3, infill%%=%d, infill_volume=%.1f mm3",
interior_volume_mm3, infill_percent, infill_volume_mm3)
total_volume_mm3 = wall_volume_mm3 + solid_volume_mm3 + infill_volume_mm3
# Sanity check: total filament volume shouldn't exceed full solid object
total_volume_mm3 = min(total_volume_mm3, volume_mm3 * 1.05)
weight_g = total_volume_mm3 / 1000.0 * density_g_cm3
logger.debug("Total filament volume: %.1f mm3, weight: %.1f g", total_volume_mm3, weight_g)
return round(weight_g, 1)
def _estimate_print_time(file_info: FileInfo, layer_height_mm: float,
infill_percent: int, weight_grams: float,
density_g_cm3: float) -> float:
"""
Estimate print time using toolpath-like model:
- Wall time: perimeter_length * wall_count * layers / wall_speed
- Infill time: infill_volume / (line_width * layer_height * infill_speed)
- Top/bottom time: solid_area * solid_layers / solid_speed
- First layer penalty
- Non-print overhead (travel, retraction, acceleration)
"""
bbox = file_info.bounding_box_mm
x = bbox.get("x", 10.0)
y = bbox.get("y", 10.0)
z = bbox.get("z", 10.0)
surface_mm2 = file_info.surface_area_cm2 * 100.0
volume_mm3 = file_info.volume_cm3 * 1000.0
footprint_mm2 = x * y * 0.65
footprint_mm2 = min(footprint_mm2, surface_mm2 * 0.4)
perimeter_per_layer_mm = max((surface_mm2 - 2 * footprint_mm2) / max(z, 1.0), 10.0)
num_layers = max(z / layer_height_mm, 1)
# Wall time: outer wall slower, inner walls faster
outer_wall_time_s = (perimeter_per_layer_mm * num_layers) / SPEED_OUTER_WALL
inner_wall_time_s = (perimeter_per_layer_mm * (WALL_COUNT - 1) * num_layers) / SPEED_INNER_WALL
wall_time_s = outer_wall_time_s + inner_wall_time_s
logger.debug("Wall time: outer=%.0fs, inner=%.0fs, total=%.0fs",
outer_wall_time_s, inner_wall_time_s, wall_time_s)
# Infill time: total infill length / speed
# Use weight-based total volume, then subtract shell (capped same as weight calc)
total_filament_mm3 = weight_grams / density_g_cm3 * 1000.0
wall_volume_mm3 = perimeter_per_layer_mm * WALL_COUNT * LINE_WIDTH_MM * layer_height_mm * num_layers
solid_volume_mm3 = footprint_mm2 * layer_height_mm * (TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS)
shell_volume_mm3 = wall_volume_mm3 + solid_volume_mm3
max_shell = volume_mm3 * 0.75
if shell_volume_mm3 > max_shell:
scale = max_shell / shell_volume_mm3
wall_volume_mm3 *= scale
solid_volume_mm3 *= scale
shell_volume_mm3 = max_shell
infill_volume_mm3 = max(total_filament_mm3 - shell_volume_mm3, 0)
infill_length_mm = infill_volume_mm3 / (LINE_WIDTH_MM * layer_height_mm)
infill_time_s = infill_length_mm / SPEED_INFILL
logger.debug("Infill time: length=%.0f mm, time=%.0fs", infill_length_mm, infill_time_s)
# Top/bottom solid time
solid_layers = TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS
solid_length_mm = (footprint_mm2 / LINE_WIDTH_MM) * solid_layers
solid_time_s = solid_length_mm / SPEED_TOP_BOTTOM
logger.debug("Solid top/bottom time: length=%.0f mm, time=%.0fs", solid_length_mm, solid_time_s)
# First layer is slower
first_layer_penalty_s = perimeter_per_layer_mm * WALL_COUNT / SPEED_FIRST_LAYER
logger.debug("First layer penalty: %.0fs", first_layer_penalty_s)
# Sum and apply overhead
raw_time_s = wall_time_s + infill_time_s + solid_time_s + first_layer_penalty_s
total_time_s = raw_time_s * NON_PRINT_OVERHEAD + SETUP_TIME_MIN * 60
hours = round(total_time_s / 3600.0, 1)
logger.debug("Print time: raw=%.0fs, with overhead=%.0fs (%.1fh)",
raw_time_s, total_time_s, hours)
return max(hours, 0.1)
def calculate_price(
file_info: FileInfo,
density_g_cm3: float,
price_per_gram: float,
flow_rate_mm3_s: float,
infill_percent: int = 30,
layer_height_mm: float = 0.2,
quantity: int = 1,
post_processing: list[str] | None = None,
multicolor: bool = False,
) -> PriceResult:
post_processing = post_processing or []
logger.info("=== Price calculation start ===")
logger.info("Input: volume=%.2f cm3, area=%.2f cm2, density=%.2f g/cm3, price=%.1f RUB/g",
file_info.volume_cm3, file_info.surface_area_cm2, density_g_cm3, price_per_gram)
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, multicolor=%s",
infill_percent, layer_height_mm, quantity, multicolor)
# Weight (slicer-like)
weight_g = _estimate_weight(file_info, density_g_cm3, infill_percent, layer_height_mm)
material_cost = round(weight_g * price_per_gram, 2)
logger.info("Weight: %.1f g, material cost: %.2f RUB", weight_g, material_cost)
# Time (toolpath-like)
print_time_h = _estimate_print_time(file_info, layer_height_mm, infill_percent,
weight_g, density_g_cm3)
time_cost = round(print_time_h * TIME_RATE_PER_HOUR, 2)
logger.info("Print time: %.1f h, time cost: %.2f RUB", print_time_h, time_cost)
# Post-processing
pp_cost = 0.0
for pp in post_processing:
cost = POST_PROCESSING_COSTS.get(pp, 0)
pp_cost += cost
pp_cost = round(pp_cost, 2)
subtotal = round(material_cost + time_cost + pp_cost, 2)
if multicolor:
multicolor_surcharge = round(subtotal * MULTICOLOR_SURCHARGE_PERCENT / 100.0, 2)
subtotal = round(subtotal + multicolor_surcharge, 2)
logger.debug("Multicolor surcharge: +%.2f RUB", multicolor_surcharge)
discount_pct = get_quantity_discount(quantity)
total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2)
logger.info("Total: %.2f RUB (qty=%d, discount=%d%%)", total, quantity, discount_pct)
if print_time_h <= 2:
estimated_days = 2
elif print_time_h <= 8:
estimated_days = 3
else:
estimated_days = 5
if quantity > 10:
estimated_days += 2
if quantity > 50:
estimated_days += 3
logger.info("=== Price calculation complete ===")
return PriceResult(
weight_grams=weight_g,
material_cost_rub=material_cost,
print_time_hours=print_time_h,
time_cost_rub=time_cost,
post_processing_cost_rub=pp_cost,
subtotal_rub=subtotal,
quantity=quantity,
quantity_discount_percent=discount_pct,
total_rub=total,
estimated_days=estimated_days,
)