mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band
Two stacked causes under-reported multi-plate prints in the project
rollup and the archive card.
Root cause 1 - parser only read plate 1.
ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
prediction / weight from that one element. Any multi-plate file's
archive-level print_time_seconds / filament_used_grams reflected
plate 1 alone. The /plates endpoint already looped findall and was
correct, which is why the plate carousel showed the right numbers
while the archive card was wrong.
Fix: loop every <plate> and sum prediction + weight. Per-plate
concepts (plate_number, _plate_index, printable_objects) only set
when there's exactly one plate - for multi-plate exports the
archive represents all plates and a single index doesn't apply at
the file level. bed_type keeps the first plate's value as a
best-effort default. Malformed prediction / weight on individual
plates skip cleanly rather than poison the sum.
Root cause 2 - project rollup aggregated PrintArchive, not the
per-run log.
compute_project_stats and list_projects quick-stats summed
PrintArchive.print_time_seconds / filament_used_grams / cost /
energy_* WHERE project_id. A reprint reuses the source archive row
and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
archive - and that archive's numbers were already plate-1-only from
cause 1. The Archive Print Log path was already correct because it
drove off print_log_entries (archives.py:420 comment).
Fix: both compute_project_stats and the list_projects quick-stats
block inner-join print_log_entries -> print_archives WHERE
archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
failed_prints counts runs in failed/aborted/cancelled/stopped,
completed_items is SUM(PrintArchive.quantity) for runs where
status='completed', time/filament/cost/energy from PrintLogEntry.
Orphan log rows (archive_id IS NULL post archive deletion) are
excluded by the inner join.
Same-shape fixes carried forward (no follow-ups per project rule):
system.py system-info totals: total_print_time / total_filament
had the same bug shape - summed PrintArchive directly so reprints
collapsed to one row. Now sums PrintLogEntry.duration_seconds /
filament_used_grams. The semantic shift is also a correctness
improvement: the field now reflects time the printer actually spent
printing, not slicer-estimated time.
archives.py time-accuracy metric: estimate / actual per run where
estimate = PrintArchive.print_time_seconds. Post-parser-fix
multi-plate archives have file-level estimate but per-run actual =
one plate, so ratio = N x 100% for an N-plate file. The calc now
clamps each row to the [50%, 200%] plausibility band before
contributing to the printer-level average; single-plate accuracy
(the case the metric is designed for) stays fully included.
Backfill: users with AMS spool tracking - the reporter's case - have
per-run filament_used_grams from the tracked spool delta, so stats
become correct immediately. Users without tracking fall back to the
archive estimate and undercount until they reprint. Archive card
still reads PrintArchive.filament_used_grams directly so old
multi-plate archives keep plate-1-only numbers until reslice -
forward-only as the reporter accepted.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -948,6 +948,23 @@ async def get_archive_stats(
|
||||
*base_conditions,
|
||||
)
|
||||
)
|
||||
# Accuracy is meaningful only when the estimate roughly describes the
|
||||
# work the run actually performed. Two shapes produce wildly-off ratios
|
||||
# that are pure noise:
|
||||
# - multi-plate ``.gcode.3mf`` printed plate-by-plate: each run's
|
||||
# actual is one plate, the archive's estimate is the sum across
|
||||
# plates (post-#1593 parser fix), so the ratio is roughly N×100%
|
||||
# for an N-plate file. Pre-fix this shape was also broken, just
|
||||
# less dramatically — the estimate was plate-1-only so the ratio
|
||||
# was meaningless rather than N×.
|
||||
# - manual interventions / purge waste blowing the actual far past
|
||||
# the estimate.
|
||||
# Clamp to the [50%, 200%] band so the printer-level average reflects
|
||||
# real slicer-vs-reality drift, not multi-plate accounting or one-off
|
||||
# outliers. Single-plate archives — the case the metric is actually
|
||||
# designed for — stay fully included.
|
||||
_ACCURACY_BAND_LO = 50.0
|
||||
_ACCURACY_BAND_HI = 200.0
|
||||
average_accuracy = None
|
||||
accuracy_by_printer: dict[str, float] = {}
|
||||
accuracies: list[float] = []
|
||||
@@ -960,6 +977,8 @@ async def get_archive_stats(
|
||||
if not actual_seconds or not estimate_seconds:
|
||||
continue
|
||||
accuracy = (estimate_seconds / actual_seconds) * 100
|
||||
if accuracy < _ACCURACY_BAND_LO or accuracy > _ACCURACY_BAND_HI:
|
||||
continue
|
||||
accuracies.append(accuracy)
|
||||
printer_key = str(run_printer_id) if run_printer_id else "unknown"
|
||||
printer_accuracies.setdefault(printer_key, []).append(accuracy)
|
||||
|
||||
@@ -20,6 +20,7 @@ from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.project_bom import ProjectBOMItem
|
||||
@@ -48,40 +49,66 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
_FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
|
||||
|
||||
|
||||
async def compute_project_stats(
|
||||
db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
|
||||
) -> ProjectStats:
|
||||
"""Compute statistics for a project."""
|
||||
# Count total archives (distinct print jobs)
|
||||
total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
|
||||
total_archives = total_result.scalar() or 0
|
||||
"""Compute statistics for a project.
|
||||
|
||||
# Sum total items (using quantity field)
|
||||
total_items_result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
|
||||
)
|
||||
total_items = total_items_result.scalar() or 0
|
||||
Aggregates from ``print_log_entries`` joined to ``print_archives`` so
|
||||
every actual run contributes — pre-fix this counted ``print_archives``
|
||||
(one row per file), which under-reported every reprint by collapsing
|
||||
runs back into the source file (#1593). The Archive Print Log view
|
||||
already drives off the same source (``archives.py::list_archives_slim``),
|
||||
so project stats now stay aligned with the per-archive numbers.
|
||||
|
||||
# Count failed archives (number of print jobs) - includes all failure states
|
||||
failed_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(
|
||||
PrintArchive.project_id == project_id,
|
||||
PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
|
||||
)
|
||||
)
|
||||
failed_prints = failed_result.scalar() or 0
|
||||
|
||||
# Sum print time, filament, and energy
|
||||
sums_result = await db.execute(
|
||||
Orphan log entries (``archive_id IS NULL`` after archive deletion via
|
||||
``ON DELETE SET NULL``) are excluded by the inner join — they can't
|
||||
be attributed to a project.
|
||||
"""
|
||||
# Per-run aggregates from print_log_entries joined on archive_id so
|
||||
# the WHERE filters by archives.project_id. Each run's duration,
|
||||
# filament, cost, and energy come from the log row, not the source
|
||||
# archive — so multi-plate 3MFs and reprints both count correctly.
|
||||
log_stats_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
|
||||
func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
|
||||
func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
|
||||
func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
|
||||
func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
|
||||
).where(PrintArchive.project_id == project_id)
|
||||
func.count(PrintLogEntry.id).label("total_runs"),
|
||||
func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
|
||||
func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
|
||||
func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
|
||||
func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
|
||||
func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
)
|
||||
sums = sums_result.first()
|
||||
log_stats = log_stats_result.first()
|
||||
total_archives = int(log_stats.total_runs or 0)
|
||||
|
||||
# Total items the project has produced or attempted: sum of quantity
|
||||
# per run (each run contributes its archive's quantity). The total/
|
||||
# completed/failed splits are all per-run, not per-file.
|
||||
items_split_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
|
||||
func.coalesce(
|
||||
func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
|
||||
0,
|
||||
).label("completed_items"),
|
||||
func.coalesce(
|
||||
func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
|
||||
0,
|
||||
).label("failed_runs"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
)
|
||||
items_split = items_split_result.first()
|
||||
total_items = int(items_split.total_items or 0)
|
||||
completed_items = int(items_split.completed_items or 0)
|
||||
failed_prints = int(items_split.failed_runs or 0)
|
||||
|
||||
# Count queued items
|
||||
queued_result = await db.execute(
|
||||
@@ -99,15 +126,6 @@ async def compute_project_stats(
|
||||
)
|
||||
in_progress_prints = in_progress_result.scalar() or 0
|
||||
|
||||
# Sum completed items (parts) - sum of quantities for actually printed jobs
|
||||
completed_items_result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == project_id,
|
||||
PrintArchive.status == "completed",
|
||||
)
|
||||
)
|
||||
completed_items = int(completed_items_result.scalar() or 0)
|
||||
|
||||
# Calculate progress for plates (target_count vs total_archives)
|
||||
progress_percent = None
|
||||
remaining_prints = None
|
||||
@@ -141,13 +159,13 @@ async def compute_project_stats(
|
||||
failed_prints=int(failed_prints),
|
||||
queued_prints=queued_prints,
|
||||
in_progress_prints=in_progress_prints,
|
||||
total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
|
||||
total_filament_grams=round(sums.total_filament or 0, 2),
|
||||
total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2),
|
||||
total_filament_grams=round(log_stats.total_filament or 0, 2),
|
||||
progress_percent=progress_percent,
|
||||
parts_progress_percent=parts_progress_percent,
|
||||
estimated_cost=round((sums.total_filament_cost or 0), 2),
|
||||
total_energy_kwh=round((sums.total_energy or 0), 3),
|
||||
total_energy_cost=round((sums.total_energy_cost or 0), 3),
|
||||
estimated_cost=round((log_stats.total_filament_cost or 0), 2),
|
||||
total_energy_kwh=round((log_stats.total_energy or 0), 3),
|
||||
total_energy_cost=round((log_stats.total_energy_cost or 0), 3),
|
||||
remaining_prints=remaining_prints,
|
||||
remaining_parts=remaining_parts,
|
||||
bom_total_items=bom_stats.total or 0,
|
||||
@@ -172,20 +190,34 @@ async def list_projects(
|
||||
result = await db.execute(query)
|
||||
projects = result.scalars().all()
|
||||
|
||||
# Compute quick stats for each project
|
||||
# Compute quick stats for each project. Same per-run aggregation as
|
||||
# ``compute_project_stats`` — counts and quantities come from
|
||||
# ``print_log_entries`` joined to ``print_archives`` so reprints and
|
||||
# multi-plate prints contribute every run, not just the source file
|
||||
# (#1593). Quick stats and the full stats endpoint must agree.
|
||||
response = []
|
||||
for project in projects:
|
||||
# Get archive count (number of print jobs)
|
||||
archive_count_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
|
||||
log_quick_result = await db.execute(
|
||||
select(
|
||||
func.count(PrintLogEntry.id).label("archive_count"),
|
||||
func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
|
||||
func.coalesce(
|
||||
func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
|
||||
0,
|
||||
).label("completed_count"),
|
||||
func.coalesce(
|
||||
func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
|
||||
0,
|
||||
).label("failed_count"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project.id)
|
||||
)
|
||||
archive_count = archive_count_result.scalar() or 0
|
||||
|
||||
# Get total items (sum of quantities)
|
||||
total_items_result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
|
||||
)
|
||||
total_items = int(total_items_result.scalar() or 0)
|
||||
log_quick = log_quick_result.first()
|
||||
archive_count = int(log_quick.archive_count or 0)
|
||||
total_items = int(log_quick.total_items or 0)
|
||||
completed_count = int(log_quick.completed_count or 0)
|
||||
failed_count = int(log_quick.failed_count or 0)
|
||||
|
||||
# Get queue count
|
||||
queue_count_result = await db.execute(
|
||||
@@ -196,24 +228,6 @@ async def list_projects(
|
||||
)
|
||||
queue_count = queue_count_result.scalar() or 0
|
||||
|
||||
# Sum completed parts (quantities) - only actually printed jobs
|
||||
completed_result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == project.id,
|
||||
PrintArchive.status == "completed",
|
||||
)
|
||||
)
|
||||
completed_count = int(completed_result.scalar() or 0)
|
||||
|
||||
# Sum failed parts (quantities) - includes all failure states
|
||||
failed_result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == project.id,
|
||||
PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
|
||||
)
|
||||
)
|
||||
failed_count = int(failed_result.scalar() or 0)
|
||||
|
||||
# Plates progress: archive_count / target_count
|
||||
progress_percent = None
|
||||
if project.target_count and project.target_count > 0:
|
||||
|
||||
@@ -19,6 +19,7 @@ from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
@@ -416,18 +417,22 @@ async def get_system_info(
|
||||
failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
|
||||
printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing"))
|
||||
|
||||
# Total print time
|
||||
# System-wide totals aggregate per-run from ``print_log_entries`` so
|
||||
# reprints contribute each run and multi-plate sums are pulled from the
|
||||
# measured per-run actuals — same source the per-archive stats and the
|
||||
# project rollup use (#1593). Pre-fix this summed ``PrintArchive`` directly,
|
||||
# which under-reported the same way the project page did (3 reprints of
|
||||
# one file showed as one file's worth of filament/time).
|
||||
total_print_time = (
|
||||
await db.scalar(
|
||||
select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None))
|
||||
select(func.sum(PrintLogEntry.duration_seconds)).where(PrintLogEntry.duration_seconds.isnot(None))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total filament used
|
||||
total_filament = (
|
||||
await db.scalar(
|
||||
select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None))
|
||||
select(func.sum(PrintLogEntry.filament_used_grams)).where(PrintLogEntry.filament_used_grams.isnot(None))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
@@ -188,49 +188,78 @@ class ThreeMFParser:
|
||||
self.metadata["sliced_for_model"] = normalized
|
||||
break
|
||||
|
||||
# Find the plate element (single-plate exports only have one plate)
|
||||
plate = root.find(".//plate")
|
||||
# Loop every <plate> so multi-plate exports get summed file-level
|
||||
# totals. Pre-fix, this used `root.find(".//plate")` which
|
||||
# returned only the first plate — file-level `print_time_seconds`
|
||||
# / `filament_used_grams` reflected plate 1 alone, and the
|
||||
# archive card / project rollup under-reported by the number
|
||||
# of plates (#1593). Per-plate breakdown is still served by
|
||||
# the dedicated `/plates` endpoint.
|
||||
plates = root.findall(".//plate")
|
||||
summed_time = 0
|
||||
summed_grams = 0.0
|
||||
any_time_seen = False
|
||||
any_grams_seen = False
|
||||
|
||||
if plate is not None:
|
||||
# Extract metadata from plate element
|
||||
for plate in plates:
|
||||
# Plate-level fields that only make sense at the file
|
||||
# level when there's exactly one plate. ``plate_number``
|
||||
# / ``_plate_index`` describe which plate the export
|
||||
# represents — meaningless for an all-plates 3MF, so we
|
||||
# only record them in the single-plate case. ``bed_type``
|
||||
# is also single-valued; we take the first plate's value
|
||||
# as a best-effort default for the archive metadata.
|
||||
plate_index_value: int | None = None
|
||||
for meta in plate.findall("metadata"):
|
||||
key = meta.get("key")
|
||||
value = meta.get("value")
|
||||
if key == "index" and value:
|
||||
# Extract plate index - this tells us which plate was exported
|
||||
try:
|
||||
extracted_index = int(value)
|
||||
# Set plate_number if not already set from filename
|
||||
if not self.plate_number:
|
||||
self.plate_number = extracted_index
|
||||
# Store in metadata for print_name generation
|
||||
self.metadata["_plate_index"] = extracted_index
|
||||
plate_index_value = int(value)
|
||||
except ValueError:
|
||||
pass # Skip non-numeric plate index
|
||||
elif key == "prediction" and value:
|
||||
self.metadata["print_time_seconds"] = int(value)
|
||||
try:
|
||||
summed_time += int(value)
|
||||
any_time_seen = True
|
||||
except ValueError:
|
||||
pass
|
||||
elif key == "weight" and value:
|
||||
self.metadata["filament_used_grams"] = float(value)
|
||||
elif key == "curr_bed_type" and value:
|
||||
try:
|
||||
summed_grams += float(value)
|
||||
any_grams_seen = True
|
||||
except ValueError:
|
||||
pass
|
||||
elif key == "curr_bed_type" and value and "bed_type" not in self.metadata:
|
||||
self.metadata["bed_type"] = value
|
||||
|
||||
# Extract printable objects for skip object functionality
|
||||
# Objects are stored as <object identify_id="123" name="Part1" skipped="false" />
|
||||
printable_objects = {}
|
||||
for obj in plate.findall("object"):
|
||||
identify_id = obj.get("identify_id")
|
||||
name = obj.get("name")
|
||||
skipped = obj.get("skipped", "false")
|
||||
# Per-plate object lists are only kept at the file level
|
||||
# when there's one plate — the skip-object affordance
|
||||
# operates on the plate being printed, which is the
|
||||
# `/plates` endpoint's job for multi-plate exports.
|
||||
if len(plates) == 1:
|
||||
if plate_index_value is not None:
|
||||
if not self.plate_number:
|
||||
self.plate_number = plate_index_value
|
||||
self.metadata["_plate_index"] = plate_index_value
|
||||
|
||||
# Only include objects that are not pre-skipped
|
||||
if identify_id and name and skipped.lower() != "true":
|
||||
try:
|
||||
printable_objects[int(identify_id)] = name
|
||||
except ValueError:
|
||||
pass # Skip objects with non-numeric identify_id
|
||||
printable_objects: dict[int, str] = {}
|
||||
for obj in plate.findall("object"):
|
||||
identify_id = obj.get("identify_id")
|
||||
name = obj.get("name")
|
||||
skipped = obj.get("skipped", "false")
|
||||
if identify_id and name and skipped.lower() != "true":
|
||||
try:
|
||||
printable_objects[int(identify_id)] = name
|
||||
except ValueError:
|
||||
pass # Skip objects with non-numeric identify_id
|
||||
if printable_objects:
|
||||
self.metadata["printable_objects"] = printable_objects
|
||||
|
||||
if printable_objects:
|
||||
self.metadata["printable_objects"] = printable_objects
|
||||
if any_time_seen:
|
||||
self.metadata["print_time_seconds"] = summed_time
|
||||
if any_grams_seen:
|
||||
self.metadata["filament_used_grams"] = round(summed_grams, 2)
|
||||
|
||||
# Get filament info from filaments ACTUALLY USED in the print
|
||||
# slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
|
||||
|
||||
@@ -287,10 +287,19 @@ class TestProjectPartsTracking:
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
"""Factory to create test archives."""
|
||||
"""Factory to create a test archive plus a matching PrintLogEntry.
|
||||
|
||||
Project stats aggregate from ``print_log_entries`` (#1593), so a
|
||||
test that only writes archives wouldn't exercise the production
|
||||
path — production always writes one log entry per run. The
|
||||
factory mirrors that: every archive whose status is anything other
|
||||
than ``"archived"`` (file shelved without printing) gets a log
|
||||
entry whose status matches the archive.
|
||||
"""
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
|
||||
defaults = {
|
||||
"filename": "test.3mf",
|
||||
@@ -306,6 +315,16 @@ class TestProjectPartsTracking:
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
|
||||
if archive.status != "archived":
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=archive.id,
|
||||
print_name=archive.print_name,
|
||||
status=archive.status,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
@@ -436,10 +455,12 @@ class TestProjectArchivedStatusNotCounted:
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
"""Factory to create test archives."""
|
||||
"""Factory to create a test archive plus a matching PrintLogEntry —
|
||||
see TestProjectPartsTracking.archive_factory for rationale (#1593)."""
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
|
||||
defaults = {
|
||||
"filename": "test.3mf",
|
||||
@@ -455,6 +476,16 @@ class TestProjectArchivedStatusNotCounted:
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
|
||||
if archive.status != "archived":
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=archive.id,
|
||||
print_name=archive.print_name,
|
||||
status=archive.status,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
@@ -499,7 +530,10 @@ class TestProjectArchivedStatusNotCounted:
|
||||
our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
|
||||
assert our_project is not None
|
||||
assert our_project["completed_count"] == 4 # Only completed, not archived
|
||||
assert our_project["archive_count"] == 2 # Both archives exist as plates
|
||||
# Post-#1593: archive_count is "print runs", not "files attached". An
|
||||
# ``archived``-status file (shelved without printing) has no
|
||||
# PrintLogEntry and doesn't count — only the actual printed run does.
|
||||
assert our_project["archive_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -519,9 +553,201 @@ class TestProjectArchivedStatusNotCounted:
|
||||
data = response.json()
|
||||
|
||||
assert data["stats"]["completed_prints"] == 10 # Only "completed"
|
||||
assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of archives, not sum)
|
||||
assert data["stats"]["total_archives"] == 4 # All archives
|
||||
assert data["stats"]["total_items"] == 20 # Sum of all quantities
|
||||
assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of runs)
|
||||
# Post-#1593: total_archives counts runs from print_log_entries, not
|
||||
# files. The ``archived`` row is a shelved file with no run, so it
|
||||
# contributes 0; the other three (completed, failed, aborted) each
|
||||
# produced a run.
|
||||
assert data["stats"]["total_archives"] == 3
|
||||
# total_items sums quantity per run: 10 (completed) + 3 (failed) + 2 (aborted) = 15
|
||||
assert data["stats"]["total_items"] == 15
|
||||
|
||||
|
||||
class TestProjectStatsPerRun:
|
||||
"""Project stats aggregate per-run from ``print_log_entries`` so
|
||||
reprints and multi-plate prints count every run (#1593). Pre-fix the
|
||||
stats counted ``print_archives`` (one row per file), so 3 reprints of
|
||||
one file showed as 1 job with plate-1-only filament/time/cost.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def project_factory(self, db_session):
|
||||
async def _create_project(**kwargs):
|
||||
from backend.app.models.project import Project
|
||||
|
||||
defaults = {"name": "Per-Run Stats Project", "color": "#FF0000"}
|
||||
defaults.update(kwargs)
|
||||
project = Project(**defaults)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
return _create_project
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_with_runs(self, db_session):
|
||||
"""Build a single archive + N PrintLogEntry rows.
|
||||
|
||||
Models the reporter's case: one source file (archive) is reprinted
|
||||
N times, each run with its own duration / filament / cost.
|
||||
"""
|
||||
|
||||
async def _create(*, project_id: int, runs: list[dict], archive_status: str = "completed", quantity: int = 1):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
|
||||
archive = PrintArchive(
|
||||
filename="reprinted.3mf",
|
||||
file_path="test/reprinted.3mf",
|
||||
file_size=1000,
|
||||
print_name="Reprinted Print",
|
||||
status=archive_status,
|
||||
quantity=quantity,
|
||||
project_id=project_id,
|
||||
)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
|
||||
for run in runs:
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=archive.id,
|
||||
print_name=archive.print_name,
|
||||
status=run.get("status", "completed"),
|
||||
duration_seconds=run.get("duration_seconds"),
|
||||
filament_used_grams=run.get("filament_used_grams"),
|
||||
cost=run.get("cost"),
|
||||
energy_kwh=run.get("energy_kwh"),
|
||||
energy_cost=run.get("energy_cost"),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return archive
|
||||
|
||||
return _create
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_three_reprints_count_as_three_jobs_with_summed_totals(
|
||||
self, async_client: AsyncClient, project_factory, archive_with_runs
|
||||
):
|
||||
"""Reporter's case: 3 runs of one multi-plate file should report
|
||||
3 jobs and summed time / filament / cost — pre-fix it reported 1
|
||||
job with plate-1-only totals."""
|
||||
project = await project_factory()
|
||||
await archive_with_runs(
|
||||
project_id=project.id,
|
||||
runs=[
|
||||
{"duration_seconds": 7140, "filament_used_grams": 19.2, "cost": 0.40},
|
||||
{"duration_seconds": 6000, "filament_used_grams": 20.0, "cost": 0.40},
|
||||
{"duration_seconds": 6300, "filament_used_grams": 18.8, "cost": 0.40},
|
||||
],
|
||||
)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}")
|
||||
assert response.status_code == 200
|
||||
stats = response.json()["stats"]
|
||||
|
||||
assert stats["total_archives"] == 3, "3 runs must show as 3 jobs"
|
||||
assert stats["completed_prints"] == 3, "Each run with quantity=1 contributes 1 part"
|
||||
assert stats["total_filament_grams"] == round(19.2 + 20.0 + 18.8, 2)
|
||||
assert stats["total_print_time_hours"] == round((7140 + 6000 + 6300) / 3600, 2)
|
||||
# Cost rounds at 2 decimals — 3 * 0.40 = 1.20
|
||||
assert stats["estimated_cost"] == 1.20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_orphan_log_entries_do_not_bleed_into_projects(
|
||||
self, async_client: AsyncClient, project_factory, db_session
|
||||
):
|
||||
"""Log rows whose ``archive_id`` is NULL (archive deleted via
|
||||
ON DELETE SET NULL) must not leak into any project — the inner
|
||||
join filters them out by construction."""
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
|
||||
project = await project_factory()
|
||||
|
||||
# Orphan log entries — no archive_id.
|
||||
for _ in range(5):
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=None,
|
||||
print_name="Orphan Run",
|
||||
status="completed",
|
||||
duration_seconds=3600,
|
||||
filament_used_grams=20.0,
|
||||
cost=0.5,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}")
|
||||
assert response.status_code == 200
|
||||
stats = response.json()["stats"]
|
||||
|
||||
# None of the orphan rows are attributable to this project.
|
||||
assert stats["total_archives"] == 0
|
||||
assert stats["completed_prints"] == 0
|
||||
assert stats["total_filament_grams"] == 0
|
||||
assert stats["total_print_time_hours"] == 0
|
||||
assert stats["estimated_cost"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_mixed_run_outcomes_split_completed_and_failed(
|
||||
self, async_client: AsyncClient, project_factory, archive_with_runs
|
||||
):
|
||||
"""A multi-run archive with mixed outcomes splits cleanly between
|
||||
completed_prints (per-quantity) and failed_prints (per-run)."""
|
||||
project = await project_factory()
|
||||
await archive_with_runs(
|
||||
project_id=project.id,
|
||||
quantity=2,
|
||||
runs=[
|
||||
{"status": "completed", "filament_used_grams": 30.0},
|
||||
{"status": "completed", "filament_used_grams": 30.0},
|
||||
{"status": "failed", "filament_used_grams": 5.0},
|
||||
{"status": "aborted", "filament_used_grams": 2.0},
|
||||
],
|
||||
)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}")
|
||||
stats = response.json()["stats"]
|
||||
|
||||
assert stats["total_archives"] == 4
|
||||
# 2 completed runs × quantity=2 each = 4 parts
|
||||
assert stats["completed_prints"] == 4
|
||||
# 2 failure runs (failed + aborted) count as 2, not 2*quantity
|
||||
assert stats["failed_prints"] == 2
|
||||
# All 4 runs contribute filament: 30 + 30 + 5 + 2 = 67
|
||||
assert stats["total_filament_grams"] == 67.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_quick_stats_in_list_view_agree_with_per_project_stats(
|
||||
self, async_client: AsyncClient, project_factory, archive_with_runs
|
||||
):
|
||||
"""The /projects list view's quick stats must agree with
|
||||
/projects/{id}'s detailed stats — both come from the same per-run
|
||||
aggregation."""
|
||||
project = await project_factory(name="Quick-Stats Alignment")
|
||||
await archive_with_runs(
|
||||
project_id=project.id,
|
||||
quantity=1,
|
||||
runs=[
|
||||
{"status": "completed"},
|
||||
{"status": "completed"},
|
||||
{"status": "failed"},
|
||||
],
|
||||
)
|
||||
|
||||
list_resp = await async_client.get("/api/v1/projects/")
|
||||
ours = next(p for p in list_resp.json() if p["name"] == "Quick-Stats Alignment")
|
||||
assert ours["archive_count"] == 3
|
||||
assert ours["completed_count"] == 2
|
||||
assert ours["failed_count"] == 1
|
||||
|
||||
|
||||
class TestProjectArchivesAPI:
|
||||
|
||||
@@ -219,10 +219,32 @@ class TestSystemAPI:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):
|
||||
"""Verify database stats include archive counts."""
|
||||
"""Verify database stats include archive counts.
|
||||
|
||||
Post-#1593 `total_print_time_seconds` is summed from
|
||||
`PrintLogEntry.duration_seconds` (the *actual* per-run duration),
|
||||
not `PrintArchive.print_time_seconds` (the slicer estimate). The
|
||||
archive_factory derives the run's duration from
|
||||
``completed_at - started_at`` on the archive, so the test sets
|
||||
those so each run carries a duration the system route can sum.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, status="completed", print_time_seconds=3600)
|
||||
await archive_factory(printer.id, status="failed", print_time_seconds=1800)
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
status="completed",
|
||||
print_time_seconds=3600,
|
||||
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
|
||||
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
status="failed",
|
||||
print_time_seconds=1800,
|
||||
started_at=datetime(2026, 5, 2, 10, 0, tzinfo=timezone.utc),
|
||||
completed_at=datetime(2026, 5, 2, 10, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.system.psutil") as mock_psutil,
|
||||
|
||||
@@ -729,3 +729,149 @@ class TestGcodeHeaderFilamentUsage:
|
||||
meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
|
||||
assert "filament_used_grams" not in meta
|
||||
assert "filament_used_mm" not in meta
|
||||
|
||||
|
||||
class TestMultiPlateSliceInfoSum:
|
||||
"""Multi-plate ``.gcode.3mf`` exports must produce file-level totals that
|
||||
are the SUM of every plate's prediction + weight, not plate-1 only.
|
||||
|
||||
Pre-fix the parser used ``root.find(".//plate")`` and only read the
|
||||
first plate's metadata, so the archive card and project rollup
|
||||
under-reported by roughly the number of plates (#1593).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _make_3mf_with_slice_info(slice_info_xml: str) -> str:
|
||||
"""Write a minimal .3mf with the given slice_info.config payload.
|
||||
|
||||
Bambu Studio's slice_info.config is the file the parser reads for
|
||||
file-level `prediction` / `weight`; the rest of the 3MF members
|
||||
aren't required for this test.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".3mf")
|
||||
os.close(fd)
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("3D/3dmodel.model", "<model/>")
|
||||
zf.writestr("Metadata/slice_info.config", slice_info_xml)
|
||||
return path
|
||||
|
||||
def test_three_plate_file_sums_prediction_and_weight(self):
|
||||
"""The reporter's case: three plates with distinct prediction +
|
||||
weight values must yield file-level totals that are the sum.
|
||||
"""
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1" />
|
||||
<metadata key="prediction" value="7140" />
|
||||
<metadata key="weight" value="19.2" />
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="2" />
|
||||
<metadata key="prediction" value="6000" />
|
||||
<metadata key="weight" value="20.0" />
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="3" />
|
||||
<metadata key="prediction" value="6300" />
|
||||
<metadata key="weight" value="18.8" />
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
|
||||
meta = parser.parse()
|
||||
assert meta["print_time_seconds"] == 7140 + 6000 + 6300 # 19440
|
||||
assert meta["filament_used_grams"] == round(19.2 + 20.0 + 18.8, 2) # 58.0
|
||||
# Multi-plate file: no single plate index should be claimed at the
|
||||
# file level — the archive represents all plates, not a specific one.
|
||||
assert parser.plate_number is None
|
||||
|
||||
def test_single_plate_file_preserves_plate_index_and_objects(self):
|
||||
"""The single-plate path must still set ``_plate_index`` and pick
|
||||
up printable objects — these only make sense when the archive
|
||||
represents exactly one plate.
|
||||
"""
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="index" value="2" />
|
||||
<metadata key="prediction" value="3600" />
|
||||
<metadata key="weight" value="50.5" />
|
||||
<metadata key="curr_bed_type" value="textured_pei" />
|
||||
<object identify_id="1" name="Part_A" skipped="false" />
|
||||
<object identify_id="2" name="Part_B" skipped="true" />
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
|
||||
meta = parser.parse()
|
||||
assert meta["print_time_seconds"] == 3600
|
||||
assert meta["filament_used_grams"] == 50.5
|
||||
# Single-plate exports surface the plate index via ``plate_number``
|
||||
# (``_plate_index`` is an internal key cleared at the end of parse).
|
||||
assert parser.plate_number == 2
|
||||
assert meta["bed_type"] == "textured_pei"
|
||||
assert meta["printable_objects"] == {1: "Part_A"}
|
||||
|
||||
def test_multi_plate_ignores_per_plate_objects(self):
|
||||
"""Multi-plate exports must NOT carry a single plate's objects at
|
||||
the file level — the ``/plates`` endpoint surfaces them per-plate.
|
||||
Conflating them would attach plate-1's parts to the whole archive.
|
||||
"""
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1" />
|
||||
<metadata key="prediction" value="1000" />
|
||||
<metadata key="weight" value="10.0" />
|
||||
<object identify_id="1" name="Part_A" skipped="false" />
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="2" />
|
||||
<metadata key="prediction" value="1500" />
|
||||
<metadata key="weight" value="15.0" />
|
||||
<object identify_id="2" name="Part_B" skipped="false" />
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
|
||||
meta = parser.parse()
|
||||
assert meta["print_time_seconds"] == 2500
|
||||
assert meta["filament_used_grams"] == 25.0
|
||||
# No archive-level object list when there's more than one plate.
|
||||
assert "printable_objects" not in meta
|
||||
assert parser.plate_number is None
|
||||
|
||||
def test_missing_or_malformed_values_are_skipped(self):
|
||||
"""A plate with a malformed prediction/weight string must skip
|
||||
that field, not poison the sum or raise — defensive parsing was
|
||||
already present per-field; the sum loop must preserve it.
|
||||
"""
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="prediction" value="100" />
|
||||
<metadata key="weight" value="not-a-number" />
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="prediction" value="200" />
|
||||
<metadata key="weight" value="5.0" />
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
meta = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)).parse()
|
||||
assert meta["print_time_seconds"] == 300
|
||||
# Only the second plate's weight contributed.
|
||||
assert meta["filament_used_grams"] == 5.0
|
||||
|
||||
@@ -245,3 +245,70 @@ async def test_soft_delete_keeps_runs_for_stats(
|
||||
stats = (await async_client.get("/api/v1/archives/stats")).json()
|
||||
assert stats["total_prints"] >= 1
|
||||
assert stats["total_filament_grams"] >= 75.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_time_accuracy_excludes_multi_plate_plate_by_plate_outliers(
|
||||
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Per-run accuracy clamps to a plausible 50%-200% band so multi-plate
|
||||
archives printed plate-by-plate don't poison the printer-level average.
|
||||
|
||||
Pre-#1593 the parser stored plate-1-only time in
|
||||
``PrintArchive.print_time_seconds``, so a plate-by-plate run produced a
|
||||
near-100% ratio by accident. Post-#1593 the field is the sum across
|
||||
plates, so each plate-by-plate run produces estimate/actual = N×100%
|
||||
for an N-plate file. Without the band filter a single 3-plate file
|
||||
printed plate-by-plate would drag the printer's accuracy reading to
|
||||
~300%, which is pure noise. The metric is designed for the
|
||||
single-plate-file case and should reflect real slicer drift there.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
|
||||
# Archive 1: single-plate file. Estimate 3600s, actual 3700s
|
||||
# → ratio 97.3% (well within band).
|
||||
single = await archive_factory(
|
||||
printer.id,
|
||||
print_time_seconds=3600,
|
||||
with_run=False,
|
||||
)
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=single.id,
|
||||
printer_id=printer.id,
|
||||
status="completed",
|
||||
duration_seconds=3700,
|
||||
)
|
||||
)
|
||||
|
||||
# Archive 2: multi-plate file (3 plates totaling 18000s). Two runs
|
||||
# printed plate-by-plate at ~6000s each — ratio 18000/6000 = 300%.
|
||||
# Both must be filtered out so the printer average stays at the
|
||||
# single-plate file's 97.3% reading.
|
||||
multi = await archive_factory(
|
||||
printer.id,
|
||||
print_time_seconds=18000,
|
||||
with_run=False,
|
||||
)
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=multi.id,
|
||||
printer_id=printer.id,
|
||||
status="completed",
|
||||
duration_seconds=6000,
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=multi.id,
|
||||
printer_id=printer.id,
|
||||
status="completed",
|
||||
duration_seconds=6100,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
body = (await async_client.get("/api/v1/archives/stats")).json()
|
||||
assert body["average_time_accuracy"] == pytest.approx(97.3, abs=0.1)
|
||||
assert body["time_accuracy_by_printer"][str(printer.id)] == pytest.approx(97.3, abs=0.1)
|
||||
|
||||
Reference in New Issue
Block a user