Added calculation for print time accuracy; Added duplicate detection and filter

This commit is contained in:
Martin Ziegler
2025-11-28 13:40:06 +01:00
parent 5c27da41c5
commit b407e1b6d8
13 changed files with 852 additions and 395 deletions
+12
View File
@@ -35,6 +35,16 @@
- Filament usage trends
- Print activity calendar
- Cost tracking
- Time accuracy tracking
- **Print Time Accuracy** - Compare estimated vs actual print times
- Color-coded badges (green=accurate, blue=faster, orange=slower)
- Per-printer accuracy statistics
- Dashboard widget showing average accuracy
- **Duplicate Detection** - Automatically detect when models have been printed before
- SHA256 content hash for exact matching
- Purple badge indicator on archive cards
- "Duplicates" collection filter to find all duplicates
- View duplicate history when viewing archive details
- **Filament Cost Tracking** - Track costs per print with customizable filament database
- **Photo Attachments** - Attach photos to archived prints for documentation
- **Failure Analysis** - Document failed prints with notes and photos
@@ -488,6 +498,8 @@ sudo journalctl -u bambusy -f
- [ ] Mobile-responsive improvements
- [ ] Printer groups/organization
- [x] Smart plug integration (Tasmota)
- [x] Print time accuracy tracking
- [x] Duplicate detection
## License
+176 -2
View File
@@ -17,6 +17,78 @@ from backend.app.services.archive import ArchiveService
router = APIRouter(prefix="/archives", tags=["archives"])
def compute_time_accuracy(archive: PrintArchive) -> dict:
"""Compute actual print time and accuracy for an archive.
Returns dict with actual_time_seconds and time_accuracy.
time_accuracy = (estimated / actual) * 100
- 100% = perfect estimate
- >100% = print was faster than estimated
- <100% = print took longer than estimated
"""
result = {"actual_time_seconds": None, "time_accuracy": None}
if archive.started_at and archive.completed_at and archive.status == "completed":
actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
if actual_seconds > 0:
result["actual_time_seconds"] = actual_seconds
if archive.print_time_seconds and archive.print_time_seconds > 0:
# Calculate accuracy as percentage
accuracy = (archive.print_time_seconds / actual_seconds) * 100
result["time_accuracy"] = round(accuracy, 1)
return result
def archive_to_response(
archive: PrintArchive,
duplicates: list[dict] | None = None,
duplicate_count: int = 0,
) -> dict:
"""Convert archive model to response dict with computed fields."""
data = {
"id": archive.id,
"printer_id": archive.printer_id,
"filename": archive.filename,
"file_path": archive.file_path,
"file_size": archive.file_size,
"content_hash": archive.content_hash,
"thumbnail_path": archive.thumbnail_path,
"timelapse_path": archive.timelapse_path,
"duplicates": duplicates,
"duplicate_count": duplicate_count if duplicates is None else len(duplicates),
"print_name": archive.print_name,
"print_time_seconds": archive.print_time_seconds,
"filament_used_grams": archive.filament_used_grams,
"filament_type": archive.filament_type,
"filament_color": archive.filament_color,
"layer_height": archive.layer_height,
"nozzle_diameter": archive.nozzle_diameter,
"bed_temperature": archive.bed_temperature,
"nozzle_temperature": archive.nozzle_temperature,
"status": archive.status,
"started_at": archive.started_at,
"completed_at": archive.completed_at,
"extra_data": archive.extra_data,
"makerworld_url": archive.makerworld_url,
"designer": archive.designer,
"is_favorite": archive.is_favorite,
"tags": archive.tags,
"notes": archive.notes,
"cost": archive.cost,
"photos": archive.photos,
"failure_reason": archive.failure_reason,
"created_at": archive.created_at,
}
# Add computed time accuracy fields
accuracy_data = compute_time_accuracy(archive)
data.update(accuracy_data)
return data
@router.get("/", response_model=list[ArchiveResponse])
async def list_archives(
printer_id: int | None = None,
@@ -26,12 +98,22 @@ async def list_archives(
):
"""List archived prints."""
service = ArchiveService(db)
return await service.list_archives(
archives = await service.list_archives(
printer_id=printer_id,
limit=limit,
offset=offset,
)
# Get set of hashes that have duplicates (efficient single query)
duplicate_hashes = await service.get_duplicate_hashes()
# Mark archives that have duplicates
result = []
for a in archives:
has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False
result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
return result
@router.get("/stats", response_model=ArchiveStats)
async def get_archive_stats(db: AsyncSession = Depends(get_db)):
@@ -86,6 +168,42 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
)
prints_by_printer = {str(k): v for k, v in printer_result.all()}
# Time accuracy statistics
# Get all completed archives with both estimated and actual times
accuracy_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.status == "completed")
.where(PrintArchive.print_time_seconds.isnot(None))
.where(PrintArchive.started_at.isnot(None))
.where(PrintArchive.completed_at.isnot(None))
)
archives_with_times = list(accuracy_result.scalars().all())
average_accuracy = None
accuracy_by_printer: dict[str, float] = {}
if archives_with_times:
accuracies = []
printer_accuracies: dict[str, list[float]] = {}
for archive in archives_with_times:
acc_data = compute_time_accuracy(archive)
if acc_data["time_accuracy"] is not None:
accuracies.append(acc_data["time_accuracy"])
# Group by printer
printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
if printer_key not in printer_accuracies:
printer_accuracies[printer_key] = []
printer_accuracies[printer_key].append(acc_data["time_accuracy"])
if accuracies:
average_accuracy = round(sum(accuracies) / len(accuracies), 1)
# Calculate per-printer averages
for printer_key, accs in printer_accuracies.items():
accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
return ArchiveStats(
total_prints=total_prints,
successful_prints=successful_prints,
@@ -95,6 +213,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
total_cost=round(total_cost, 2),
prints_by_filament_type=prints_by_filament,
prints_by_printer=prints_by_printer,
average_time_accuracy=average_accuracy,
time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
)
@@ -105,7 +225,16 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
archive = await service.get_archive(archive_id)
if not archive:
raise HTTPException(404, "Archive not found")
return archive
# Find duplicates
makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
duplicates = await service.find_duplicates(
archive_id=archive.id,
content_hash=archive.content_hash,
print_name=archive.print_name,
makerworld_model_id=makerworld_id,
)
return archive_to_response(archive, duplicates)
@router.patch("/{archive_id}", response_model=ArchiveResponse)
@@ -242,6 +371,51 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
return {"updated": updated, "errors": errors}
@router.get("/{archive_id}/duplicates")
async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
"""Get duplicates for a specific archive."""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
if not archive:
raise HTTPException(404, "Archive not found")
makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
duplicates = await service.find_duplicates(
archive_id=archive.id,
content_hash=archive.content_hash,
print_name=archive.print_name,
makerworld_model_id=makerworld_id,
)
return {"duplicates": duplicates, "count": len(duplicates)}
@router.post("/backfill-hashes")
async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
"""Compute and store content hashes for all archives missing them."""
result = await db.execute(
select(PrintArchive).where(PrintArchive.content_hash.is_(None))
)
archives = list(result.scalars().all())
updated = 0
errors = []
for archive in archives:
try:
file_path = settings.base_dir / archive.file_path
if not file_path.exists():
errors.append({"id": archive.id, "error": "File not found"})
continue
archive.content_hash = ArchiveService.compute_file_hash(file_path)
updated += 1
except Exception as e:
errors.append({"id": archive.id, "error": str(e)})
await db.commit()
return {"updated": updated, "errors": errors}
@router.delete("/{archive_id}")
async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
"""Delete an archive."""
+9
View File
@@ -55,3 +55,12 @@ async def run_migrations(conn):
except Exception:
# Column already exists
pass
# Migration: Add content_hash column to print_archives for duplicate detection
try:
await conn.execute(text(
"ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"
))
except Exception:
# Column already exists
pass
+1
View File
@@ -15,6 +15,7 @@ class PrintArchive(Base):
filename: Mapped[str] = mapped_column(String(255))
file_path: Mapped[str] = mapped_column(String(500))
file_size: Mapped[int] = mapped_column(Integer)
content_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 hash for duplicate detection
thumbnail_path: Mapped[str | None] = mapped_column(String(500))
timelapse_path: Mapped[str | None] = mapped_column(String(500))
+19 -1
View File
@@ -15,17 +15,32 @@ class ArchiveUpdate(ArchiveBase):
printer_id: int | None = None
class ArchiveDuplicate(BaseModel):
"""Reference to a duplicate archive."""
id: int
print_name: str | None
created_at: datetime
match_type: str # "exact" (hash match) or "similar" (name match)
class ArchiveResponse(BaseModel):
id: int
printer_id: int | None
filename: str
file_path: str
file_size: int
content_hash: str | None
thumbnail_path: str | None
timelapse_path: str | None
# Duplicate detection
duplicates: list[ArchiveDuplicate] | None = None
duplicate_count: int = 0 # Quick count for list views
print_name: str | None
print_time_seconds: int | None
print_time_seconds: int | None # Estimated time from slicer
actual_time_seconds: int | None = None # Computed from started_at/completed_at
time_accuracy: float | None = None # Percentage: 100 = perfect, >100 = faster than estimated
filament_used_grams: float | None
filament_type: str | None
filament_color: str | None
@@ -65,6 +80,9 @@ class ArchiveStats(BaseModel):
total_cost: float
prints_by_filament_type: dict
prints_by_printer: dict
# Time accuracy stats
average_time_accuracy: float | None = None # Average across all prints with data
time_accuracy_by_printer: dict | None = None # Per-printer accuracy
class ProjectPageImage(BaseModel):
+100 -1
View File
@@ -1,3 +1,4 @@
import hashlib
import json
import zipfile
import shutil
@@ -6,7 +7,7 @@ from pathlib import Path
from xml.etree import ElementTree as ET
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, and_, or_
from backend.app.core.config import settings
from backend.app.models.archive import PrintArchive
@@ -470,6 +471,100 @@ class ArchiveService:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def compute_file_hash(file_path: Path) -> str:
"""Compute SHA256 hash of a file for duplicate detection."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
# Read in chunks to handle large files
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
async def get_duplicate_hashes(self) -> set[str]:
"""Get all content hashes that appear more than once.
Returns a set of hashes that have duplicates.
"""
from sqlalchemy import func
result = await self.db.execute(
select(PrintArchive.content_hash)
.where(PrintArchive.content_hash.isnot(None))
.group_by(PrintArchive.content_hash)
.having(func.count(PrintArchive.id) > 1)
)
return {row[0] for row in result.all()}
async def find_duplicates(
self,
archive_id: int,
content_hash: str | None = None,
print_name: str | None = None,
makerworld_model_id: str | None = None,
) -> list[dict]:
"""Find duplicate archives based on hash or name matching.
Returns list of dicts with id, print_name, created_at, match_type.
"""
duplicates = []
# First, find exact matches by content hash
if content_hash:
result = await self.db.execute(
select(PrintArchive)
.where(
and_(
PrintArchive.content_hash == content_hash,
PrintArchive.id != archive_id,
)
)
.order_by(PrintArchive.created_at.desc())
.limit(10)
)
for archive in result.scalars().all():
duplicates.append({
"id": archive.id,
"print_name": archive.print_name,
"created_at": archive.created_at,
"match_type": "exact",
})
# Then, find similar matches by print name or MakerWorld ID
if print_name or makerworld_model_id:
conditions = [PrintArchive.id != archive_id]
name_conditions = []
if print_name:
# Match if print names are similar (ignoring case)
name_conditions.append(PrintArchive.print_name.ilike(print_name))
if makerworld_model_id:
# Match by MakerWorld model ID stored in extra_data
name_conditions.append(
PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id
)
if name_conditions:
conditions.append(or_(*name_conditions))
result = await self.db.execute(
select(PrintArchive)
.where(and_(*conditions))
.order_by(PrintArchive.created_at.desc())
.limit(10)
)
for archive in result.scalars().all():
# Don't add if already in duplicates (exact match)
if not any(d["id"] == archive.id for d in duplicates):
duplicates.append({
"id": archive.id,
"print_name": archive.print_name,
"created_at": archive.created_at,
"match_type": "similar",
})
return duplicates
async def archive_print(
self,
printer_id: int | None,
@@ -498,6 +593,9 @@ class ArchiveService:
dest_file = archive_dir / source_file.name
shutil.copy2(source_file, dest_file)
# Compute content hash for duplicate detection
content_hash = self.compute_file_hash(dest_file)
# Parse 3MF metadata
parser = ThreeMFParser(dest_file)
metadata = parser.parse()
@@ -526,6 +624,7 @@ class ArchiveService:
filename=source_file.name,
file_path=str(dest_file.relative_to(settings.base_dir)),
file_size=dest_file.stat().st_size,
content_hash=content_hash,
thumbnail_path=thumbnail_path,
print_name=metadata.get("print_name") or source_file.stem,
print_time_seconds=metadata.get("print_time_seconds"),
+20
View File
@@ -66,16 +66,28 @@ export interface PrinterCreate {
}
// Archive types
export interface ArchiveDuplicate {
id: number;
print_name: string | null;
created_at: string;
match_type: 'exact' | 'similar'; // 'exact' = hash match, 'similar' = name match
}
export interface Archive {
id: number;
printer_id: number | null;
filename: string;
file_path: string;
file_size: number;
content_hash: string | null;
thumbnail_path: string | null;
timelapse_path: string | null;
duplicates: ArchiveDuplicate[] | null;
duplicate_count: number;
print_name: string | null;
print_time_seconds: number | null;
actual_time_seconds: number | null; // Computed from started_at/completed_at
time_accuracy: number | null; // Percentage: 100 = perfect, >100 = faster than estimated
filament_used_grams: number | null;
filament_type: string | null;
filament_color: string | null;
@@ -107,6 +119,8 @@ export interface ArchiveStats {
total_cost: number;
prints_by_filament_type: Record<string, number>;
prints_by_printer: Record<string, number>;
average_time_accuracy: number | null;
time_accuracy_by_printer: Record<string, number> | null;
}
export interface BulkUploadResult {
@@ -296,6 +310,12 @@ export const api = {
deleteArchive: (id: number) =>
request<void>(`/archives/${id}`, { method: 'DELETE' }),
getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
getArchiveDuplicates: (id: number) =>
request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
backfillContentHashes: () =>
request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', {
method: 'POST',
}),
getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
+35 -4
View File
@@ -293,6 +293,16 @@ function ArchiveCard({
failed
</div>
)}
{/* Duplicate badge */}
{archive.duplicate_count > 0 && (
<div
className="absolute top-2 right-2 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
title="This model has been printed before"
>
<Copy className="w-3 h-3" />
duplicate
</div>
)}
{/* Timelapse badge */}
{archive.timelapse_path && (
<button
@@ -335,10 +345,27 @@ function ArchiveCard({
{/* Stats */}
<div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
{archive.print_time_seconds && (
<div className="flex items-center gap-1.5 text-bambu-gray">
{(archive.print_time_seconds || archive.actual_time_seconds) && (
<div className="flex items-center gap-1.5 text-bambu-gray" title={
archive.time_accuracy
? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
: archive.actual_time_seconds
? `Actual: ${formatDuration(archive.actual_time_seconds)}`
: `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`
}>
<Clock className="w-3 h-3" />
{formatDuration(archive.print_time_seconds)}
{formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)}
{archive.time_accuracy && (
<span className={`text-[10px] px-1 rounded ${
archive.time_accuracy >= 95 && archive.time_accuracy <= 105
? 'bg-bambu-green/20 text-bambu-green'
: archive.time_accuracy > 105
? 'bg-blue-500/20 text-blue-400'
: 'bg-orange-500/20 text-orange-400'
}`}>
{archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}%
</span>
)}
</div>
)}
{archive.filament_used_grams && (
@@ -621,7 +648,7 @@ function ArchiveCard({
type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
type ViewMode = 'grid' | 'list' | 'calendar';
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed';
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
{ id: 'all', label: 'All Archives', icon: <FolderOpen className="w-4 h-4" /> },
@@ -630,6 +657,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
{ id: 'this-month', label: 'This Month', icon: <Calendar className="w-4 h-4" /> },
{ id: 'favorites', label: 'Favorites', icon: <Star className="w-4 h-4" /> },
{ id: 'failed', label: 'Failed Prints', icon: <AlertCircle className="w-4 h-4" /> },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="w-4 h-4" /> },
];
export function ArchivesPage() {
@@ -716,6 +744,9 @@ export function ArchivesPage() {
case 'failed':
matchesCollection = a.status === 'failed';
break;
case 'duplicates':
matchesCollection = a.duplicate_count > 0;
break;
}
// Search filter
+91
View File
@@ -6,6 +6,7 @@ import {
XCircle,
DollarSign,
Printer,
Target,
} from 'lucide-react';
import { api } from '../api/client';
import { PrintCalendar } from '../components/PrintCalendar';
@@ -118,6 +119,90 @@ function SuccessRateWidget({
);
}
function TimeAccuracyWidget({
stats,
printerMap,
}: {
stats: {
average_time_accuracy: number | null;
time_accuracy_by_printer: Record<string, number> | null;
} | undefined;
printerMap: Map<string, string>;
}) {
const accuracy = stats?.average_time_accuracy;
if (accuracy === null || accuracy === undefined) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
</div>
);
}
// Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)
const displayValue = Math.min(150, Math.max(50, accuracy));
const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100
// Color based on accuracy
const getColor = (acc: number) => {
if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%
if (acc > 105) return '#3b82f6'; // Blue - faster than expected
return '#f97316'; // Orange - slower than expected
};
const color = getColor(accuracy);
const deviation = accuracy - 100;
return (
<div className="flex items-center gap-6">
<div className="relative w-28 h-28">
<svg className="w-full h-full -rotate-90">
<circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
<circle
cx="56"
cy="56"
r="48"
fill="none"
stroke={color}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={`${normalizedForGauge * 3.02} 302`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
<span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
{deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
</span>
</div>
</div>
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2 text-xs text-bambu-gray">
<Target className="w-3 h-3" />
<span>100% = perfect estimate</span>
</div>
{stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
<div className="space-y-1 mt-2">
{Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
<div key={printerId} className="flex items-center justify-between text-xs">
<span className="text-bambu-gray truncate max-w-[100px]">
{printerMap.get(printerId) || `Printer ${printerId}`}
</span>
<span className={`font-medium ${
acc >= 95 && acc <= 105 ? 'text-bambu-green' :
acc > 105 ? 'text-blue-400' : 'text-orange-400'
}`}>
{acc.toFixed(0)}%
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
function FilamentTypesWidget({
stats,
}: {
@@ -253,6 +338,12 @@ export function StatsPage() {
component: <SuccessRateWidget stats={stats} />,
defaultSize: 1,
},
{
id: 'time-accuracy',
title: 'Time Accuracy',
component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
defaultSize: 1,
},
{
id: 'filament-types',
title: 'Filament Types',
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-Qa2rW77Z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVoz8SLN.css">
<script type="module" crossorigin src="/assets/index-DZsA8zAw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwtaaDcN.css">
</head>
<body>
<div id="root"></div>