Files
bambuddy/backend/app/schemas/archive.py
maziggy d68724c689 feat(stats): energy usage in cost records and trends (#1432)
The Most Expensive record on the Statistics page ranked prints by
filament cost alone, ignoring the per-print energy cost Bambuddy
already measures via an attached smart plug. It now ranks by
filament + measured energy cost; prints without smart-plug data
compete on filament cost alone, as before.

Filament Trends gains an Energy Over Time chart: kWh per day (per
hour for ranges of a week or less, per week for long ranges), with
the range's total kWh and energy cost in the header. The chart only
renders when the selected range contains measured energy data, so
setups without smart plugs see no change.

The /archives/slim stats feed now carries each run's energy_kwh /
energy_cost from print_log_entries. Translated in all locales.
Covered by backend and frontend tests.
2026-07-28 10:27:45 +02:00

225 lines
7.1 KiB
Python

from datetime import datetime
from pydantic import BaseModel, model_validator
class ArchiveBase(BaseModel):
print_name: str | None = None
is_favorite: bool | None = None
tags: str | None = None
notes: str | None = None
cost: float | None = None
failure_reason: str | None = None
quantity: int | None = None # Number of items printed
# User-defined link (Printables, Thingiverse, etc.)
external_url: str | None = None
class ArchiveUpdate(ArchiveBase):
printer_id: int | None = None
project_id: int | None = None
# Allow changing status (e.g., clearing failed flag)
status: str | None = None
class ArchiveDuplicate(BaseModel):
"""Reference to a duplicate archive."""
id: int
print_name: str | None
created_at: datetime | None
match_type: str # "exact" (hash match) or "similar" (name match)
class ArchiveResponse(BaseModel):
id: int
printer_id: int | None
project_id: int | None = None
project_name: str | None = None # Included for convenience
filename: str
file_path: str
file_size: int
content_hash: str | None
thumbnail_path: str | None
timelapse_path: str | None
source_3mf_path: str | None = None # Original project 3MF from slicer
f3d_path: str | None = None # Fusion 360 design file
# Duplicate detection
duplicates: list[ArchiveDuplicate] | None = None
duplicate_count: int = 0 # Quick count for list views
duplicate_sequence: int = 0 # 0 = original, 1+ = nth duplicate
original_archive_id: int | None = None # ID of the first/original archive
# Object count (computed from extra_data.printable_objects)
object_count: int | None = None
print_name: str | None
plate_id: int | None = None # Selected plate of a multi-plate 3MF (#2603)
print_time_seconds: int | None # Estimated time from slicer
actual_time_seconds: int | None = None # Computed from started_at/completed_at
# Percentage: 100 = perfect, >100 = faster than estimated
time_accuracy: float | None = None
filament_used_grams: float | None
filament_type: str | None
filament_color: str | None
layer_height: float | None
total_layers: int | None = None
nozzle_diameter: float | None
bed_temperature: int | None
bed_type: str | None = None # e.g. "Cool Plate", "Textured PEI Plate" (from 3MF curr_bed_type)
nozzle_temperature: int | None
sliced_for_model: str | None = None # Printer model this file was sliced for
status: str
started_at: datetime | None
completed_at: datetime | None
extra_data: dict | None
makerworld_url: str | None
designer: str | None
# User-defined link (Printables, Thingiverse, etc.)
external_url: str | None = None
is_favorite: bool
tags: str | None
notes: str | None
cost: float | None
photos: list | None
failure_reason: str | None
quantity: int = 1 # Number of items printed
# Energy tracking
energy_kwh: float | None = None
energy_cost: float | None = None
created_at: datetime | None
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
# Per-archive run aggregates (#1378). Computed from PrintLogEntry — one
# row per actual print event — so reprints contribute to these counters
# without overwriting the source archive's first-run data.
run_count: int = 0
last_run_at: datetime | None = None
total_filament_actual_grams: float | None = None
successful_run_count: int = 0
failed_run_count: int = 0
@model_validator(mode="after")
def compute_object_count(self) -> "ArchiveResponse":
"""Compute object_count from extra_data.printable_objects if not set."""
if self.object_count is None and self.extra_data:
printable_objects = self.extra_data.get("printable_objects")
if printable_objects and isinstance(printable_objects, dict):
self.object_count = len(printable_objects)
return self
class Config:
from_attributes = True
class ArchiveSlim(BaseModel):
"""Lightweight archive response for stats/dashboard widgets."""
printer_id: int | None
print_name: str | None
print_time_seconds: int | None
actual_time_seconds: int | None = None
filament_used_grams: float | None
filament_type: str | None
filament_color: str | None
status: str
started_at: datetime | None
completed_at: datetime | None
cost: float | None
energy_kwh: float | None = None
energy_cost: float | None = None
quantity: int = 1
created_at: datetime | None
class Config:
from_attributes = True
class ArchiveStats(BaseModel):
total_prints: int
successful_prints: int
failed_prints: int
# User/system-stopped prints (PrintLogEntry.status in stopped/cancelled/
# skipped). Defaulted so older clients that don't send this field still
# validate against historical fixtures.
cancelled_prints: int = 0
total_print_time_hours: float
total_filament_grams: float
total_cost: float
prints_by_filament_type: dict
prints_by_printer: dict
# Time accuracy stats
# Average across all prints with data
average_time_accuracy: float | None = None
time_accuracy_by_printer: dict | None = None # Per-printer accuracy
# Energy stats
total_energy_kwh: float = 0.0
total_energy_cost: float = 0.0
# Set when the date-range query in "total consumption" mode is running on
# incomplete snapshot history — e.g. right after a fresh upgrade before the
# hourly snapshot loop has built up a baseline. Frontend shows a tooltip.
energy_data_warming_up: bool = False
class ProjectPageImage(BaseModel):
"""Image embedded in 3MF project page."""
name: str
path: str # Path within 3MF
url: str # API URL to fetch image
class ProjectPageResponse(BaseModel):
"""Project page data extracted from 3MF file."""
# Model info
title: str | None = None
description: str | None = None # HTML content
designer: str | None = None
designer_user_id: str | None = None
license: str | None = None
copyright: str | None = None
creation_date: str | None = None
modification_date: str | None = None
origin: str | None = None # "original" or "remix"
# Profile info
profile_title: str | None = None
profile_description: str | None = None
profile_cover: str | None = None
profile_user_id: str | None = None
profile_user_name: str | None = None
# MakerWorld info
design_model_id: str | None = None
design_profile_id: str | None = None
design_region: str | None = None
# Images
model_pictures: list[ProjectPageImage] = []
profile_pictures: list[ProjectPageImage] = []
thumbnails: list[ProjectPageImage] = []
class ProjectPageUpdate(BaseModel):
"""Update project page data in 3MF file."""
title: str | None = None
description: str | None = None
designer: str | None = None
license: str | None = None
copyright: str | None = None
profile_title: str | None = None
profile_description: str | None = None