fix(stats): align Filament Used / By Time / Success Rate with Total Consumed and Total Prints (#1390 follow-up)

Three independent root causes behind the divergences the reporter
  flagged after the archived-spool fix shipped — fixed together.

  (1) Filament Used vs Total Consumed.
  _compute_run_filament_grams returned the slicer estimate for completed
  prints even when inventory had measured the actual AMS weight delta.
  That made Stats and Inventory two different sources of truth: Stats
  showed slicer-estimate grams, Inventory showed AMS-tracked grams, and
  the two never agreed. Reordered the helper so the tracked spool delta
  (same source that drives weight_used behind Total Consumed) takes
  priority for every status. Slicer estimate stays as the fallback when
  no inventory was tracked; partial-progress scale stays as the fallback
  for failed/cancelled with no tracker. The _run_cost block right next
  to it was already tracker-first; only filament_used_grams was
  inconsistent.

  (2) Printer Stats By Time vs Quick Stats Print Time.
  /archives/slim only set actual_time_seconds when status == "completed".
  For failed/cancelled rows the frontend fell back to print_time_seconds
  (the slicer's full-print estimate — wrong number for a print that
  failed at 15%). Quick Stats already summed elapsed duration across
  all statuses, so the two halves of the page disagreed by the
  (estimate - actual-elapsed) gap on every non-completed event. Dropped
  the completed-only gate; failed/cancelled now report measured elapsed.

  (3) Success Rate %.
  Was successful / (successful + failed), excluding cancelled / stopped
  from the denominator. With "Total Prints: N" displayed right above
  the gauge that produced confusing numbers — 4 successful, 0 failed,
  48 cancelled showed 100% out of an apparent 52 prints. Switched to
  successful / total_prints — matches the count the user reads from
  the widget header.
This commit is contained in:
maziggy
2026-05-19 10:53:36 +02:00
parent 43510b9fc6
commit fc32b388de
9 changed files with 538 additions and 456 deletions
+2
View File
File diff suppressed because one or more lines are too long
+8 -5
View File
@@ -466,14 +466,17 @@ async def list_archives_slim(
"print_name": r.print_name,
"print_time_seconds": r.print_time_seconds,
"actual_time_seconds": (
# Measured elapsed time for every status (#1390): failed /
# cancelled prints still ran for some duration, and Quick
# Stats already counts that. Widgets that fall back to
# print_time_seconds (slicer estimate) for non-completed
# events would diverge from Quick Stats — so expose the
# measured value here unconditionally.
r.duration_seconds
if r.duration_seconds and r.duration_seconds > 0 and r.status == "completed"
if r.duration_seconds and r.duration_seconds > 0
else (
int((r.completed_at - r.started_at).total_seconds())
if r.started_at
and r.completed_at
and r.status == "completed"
and (r.completed_at - r.started_at).total_seconds() > 0
if r.started_at and r.completed_at and (r.completed_at - r.started_at).total_seconds() > 0
else None
)
),
+12 -10
View File
@@ -593,22 +593,24 @@ def _compute_run_filament_grams(
progress: float | int | None,
usage_results: list[dict] | None,
) -> float | None:
"""Per-run filament for PrintLogEntry, partial-aware (#1378).
"""Per-run filament for PrintLogEntry, partial- and tracker-aware (#1378, #1390).
For ``completed``: returns the archive's slicer estimate (which approximates
actual since the print finished). For failed / cancelled / stopped:
1. Sum of tracked spool deltas in ``usage_results`` (most accurate
when inventory is configured for the print).
2. ``estimate * progress%`` (when no inventory delta available).
3. ``None`` (no signal at all — e.g. progress=0 and no spool data).
Priority for every status:
1. Sum of tracked spool deltas in ``usage_results`` (AMS-measured
weight delta — same source that drives "Total Consumed" on the
Inventory page, so Stats and Inventory totals stay aligned).
2. For ``completed``: the slicer estimate (no tracker available, fall
back to the canonical "this print used X" value).
3. For partial statuses: ``estimate * progress%``.
4. ``None`` if nothing is known.
"""
if status == "completed":
return archive_filament_used_grams
tracked_grams = sum(r.get("weight_used") or 0 for r in (usage_results or []))
if tracked_grams > 0:
return round(tracked_grams, 1)
if status == "completed":
return archive_filament_used_grams
if archive_filament_used_grams:
scale = max(0.0, min(((progress or 0) / 100.0), 1.0))
if scale > 0:
@@ -529,10 +529,13 @@ class TestArchivesSlimAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_actual_time_null_for_failed(
async def test_slim_actual_time_for_failed_includes_elapsed(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify actual_time_seconds is null for non-completed prints."""
"""Failed prints report measured elapsed time so Printer Stats By Time
matches Quick Stats Print Time (#1390). Previously this returned null
and the frontend fell back to the slicer estimate, double-counting the
unfinished portion of the print."""
from datetime import datetime, timezone
printer = await printer_factory()
@@ -547,7 +550,7 @@ class TestArchivesSlimAPI:
assert response.status_code == 200
item = response.json()[0]
assert item["actual_time_seconds"] is None
assert item["actual_time_seconds"] == 3600
@pytest.mark.asyncio
@pytest.mark.integration
+11 -9
View File
@@ -1,23 +1,25 @@
"""Unit tests for the per-run filament helper (#1378).
"""Unit tests for the per-run filament helper (#1378, #1390).
The helper computes what value to write into PrintLogEntry.filament_used_grams
for a given print event — partial-aware so failed / cancelled / stopped prints
don't inflate stats with the full slicer estimate.
don't inflate stats with the full slicer estimate, and tracker-aware so
completed prints agree with the per-spool counter on the Inventory page.
"""
from backend.app.main import _compute_run_filament_grams
class TestComputeRunFilamentGrams:
def test_completed_returns_archive_estimate(self):
# Completed print: the slicer estimate is approximately what was used.
def test_completed_no_tracker_returns_archive_estimate(self):
# Completed print without inventory tracking: the slicer estimate is
# the canonical "this print used X" value.
assert _compute_run_filament_grams("completed", 100.0, 100, []) == 100.0
def test_completed_returns_estimate_even_when_tracked_differs(self):
# When a print completes, the estimate is the canonical "this print used X"
# value — the tracked spool delta might be lower (some slots untracked)
# but the print is done, so the full estimate is the right answer.
assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 10}]) == 100.0
def test_completed_prefers_tracked_over_estimate(self):
# #1390: when inventory tracked the AMS weight delta, Stats should
# reflect that — same source that drives "Total Consumed" on the
# Inventory page. Two halves of the app must show the same number.
assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 96.5}]) == 96.5
def test_failed_uses_tracked_spool_delta(self):
# Failed reprint at 10g actual: inventory tracked the spool delta.
@@ -210,10 +210,33 @@ describe('StatsPage', () => {
await waitFor(() => {
expect(screen.getByText('Success Rate')).toBeInTheDocument();
// Success rate: 140/(140+10) = 93%
// Success rate: 140 / 150 total = 93%
expect(screen.getByText('93%')).toBeInTheDocument();
});
});
it('uses total_prints as denominator so cancelled/stopped events count (#1390)', async () => {
// 40 successful out of 100 total — with 20 failed and 40 cancelled/stopped
// mixed in. Old formula (successful / (successful + failed)) would have
// shown 40 / (40 + 20) = 67%. New formula shows 40 / 100 = 40%, which
// matches the "Total Prints: 100" the user reads right above the gauge.
server.use(
http.get('/api/v1/archives/stats', () =>
HttpResponse.json({
...mockStats,
total_prints: 100,
successful_prints: 40,
failed_prints: 20,
}),
),
);
render(<StatsPage />);
await waitFor(() => {
expect(screen.getByText('Success Rate')).toBeInTheDocument();
expect(screen.getByText('40%')).toBeInTheDocument();
});
});
});
describe('cost display', () => {
@@ -353,6 +376,48 @@ describe('StatsPage', () => {
});
});
it('Longest Print excludes failed prints (#1390)', async () => {
// After slim started populating actual_time_seconds for non-completed
// rows (so Printer Stats By Time would match Quick Stats), a failed
// print's elapsed duration could outrank successful prints in the
// Records widget. RecordsWidget gates "Longest Print" on
// status === 'completed' to preserve the pre-fix semantic.
server.use(
http.get('/api/v1/archives/slim', () =>
HttpResponse.json([
{
id: 10, created_at: '2024-02-01T10:00:00Z',
started_at: '2024-02-01T10:00:00Z', completed_at: null,
print_name: 'Aborted 25h Marathon', status: 'failed',
printer_id: 1, filament_type: 'PLA', filament_color: '#000000',
filament_used_grams: 50, actual_time_seconds: 90000,
print_time_seconds: 86400, cost: 1.50, quantity: 1,
},
{
id: 11, created_at: '2024-02-02T10:00:00Z',
started_at: '2024-02-02T10:00:00Z',
completed_at: '2024-02-02T18:00:00Z',
print_name: 'Successful 8h Print', status: 'completed',
printer_id: 1, filament_type: 'PLA', filament_color: '#FF0000',
filament_used_grams: 80, actual_time_seconds: 28800,
print_time_seconds: 27000, cost: 2.40, quantity: 1,
},
]),
),
);
render(<StatsPage />);
// Wait for the records widget itself to render.
await waitFor(() => {
expect(screen.getByText('Longest Print')).toBeInTheDocument();
});
// The failed 25h print must not surface as any record — its presence
// anywhere here would mean the status gate regressed.
expect(screen.queryByText('Aborted 25h Marathon')).not.toBeInTheDocument();
// The completed 8h print is the only candidate left, so it wins.
expect(screen.getAllByText('Successful 8h Print').length).toBeGreaterThan(0);
});
it('shows heaviest print record', async () => {
render(<StatsPage />);
+9 -4
View File
@@ -197,9 +197,8 @@ function SuccessRateWidget({
size?: 1 | 2 | 4;
}) {
const { t } = useTranslation();
const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);
const successRate = completedAndFailed
? Math.round((stats!.successful_prints / completedAndFailed) * 100)
const successRate = stats?.total_prints
? Math.round(((stats.successful_prints || 0) / stats.total_prints) * 100)
: 0;
// Scale gauge size based on widget size
@@ -845,7 +844,13 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
return { archive: best, value: bestVal };
};
const longest = findMax(a => a.actual_time_seconds);
// Only completed prints qualify as the "longest" record. Pre-#1390 this
// happened implicitly because the slim endpoint returned null
// actual_time_seconds for non-completed rows; that gate moved up to the
// backend so failed/cancelled prints now carry their elapsed duration
// (Quick Stats Print Time needs that), but a partially-completed 20-hour
// run shouldn't outrank a successful 18-hour print here.
const longest = findMax(a => (a.status === 'completed' ? a.actual_time_seconds : null));
if (longest.archive) {
result.push({
icon: Clock, iconColor: 'text-blue-400', label: t('stats.longestPrint'),
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BD1bsaGc.js"></script>
<script type="module" crossorigin src="/assets/index-CDuyQ_oR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
</head>
<body>