mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user