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:
maziggy
2026-06-02 13:35:06 +02:00
parent c9bc5eb4e9
commit 1e08c25a9f
9 changed files with 641 additions and 112 deletions
+1
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -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)
+84 -70
View File
@@ -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:
+9 -4
View File
@@ -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
)
+58 -29
View File
@@ -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" />
+232 -6
View File
@@ -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:
+25 -3
View File
@@ -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)