|
@@ -9,6 +9,7 @@ from dataclasses import replace
|
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
from html import escape as html_escape
|
|
from html import escape as html_escape
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
|
|
+from types import SimpleNamespace
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
|
|
from fastapi.responses import FileResponse, Response
|
|
from fastapi.responses import FileResponse, Response
|
|
@@ -58,7 +59,11 @@ from backend.app.services.print_confirmation import (
|
|
|
is_one_tap_request,
|
|
is_one_tap_request,
|
|
|
is_unattended_fetch,
|
|
is_unattended_fetch,
|
|
|
)
|
|
)
|
|
|
-from backend.app.services.print_cost_estimate import default_cost_per_kg as _default_cost_per_kg, price_one_print
|
|
|
|
|
|
|
+from backend.app.services.print_cost_estimate import (
|
|
|
|
|
+ default_cost_per_kg as _default_cost_per_kg,
|
|
|
|
|
+ price_one_print,
|
|
|
|
|
+ run_estimate,
|
|
|
|
|
+)
|
|
|
from backend.app.services.print_log import owns_media, runs_with_timelapse
|
|
from backend.app.services.print_log import owns_media, runs_with_timelapse
|
|
|
from backend.app.services.print_storage import (
|
|
from backend.app.services.print_storage import (
|
|
|
REASON_FTP_TRANSFER_FAILED,
|
|
REASON_FTP_TRANSFER_FAILED,
|
|
@@ -315,53 +320,69 @@ def _apply_run_user_filter(conditions: list, created_by_id: int | None):
|
|
|
conditions.append(PrintLogEntry.created_by_id == created_by_id)
|
|
conditions.append(PrintLogEntry.created_by_id == created_by_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _event_estimate_seconds(row) -> int | None:
|
|
|
|
|
+ """The slicer's time for the plate one print event printed (#3375)."""
|
|
|
|
|
+ estimate = run_estimate(
|
|
|
|
|
+ SimpleNamespace(
|
|
|
|
|
+ plate_estimates=row.plate_estimates,
|
|
|
|
|
+ print_time_seconds=row.print_time_seconds,
|
|
|
|
|
+ filament_used_grams=row.file_grams,
|
|
|
|
|
+ ),
|
|
|
|
|
+ row.plate_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return estimate.seconds if estimate is not None else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def compute_time_accuracy(archive: PrintArchive, run_aggregate: dict | None = None) -> dict:
|
|
def compute_time_accuracy(archive: PrintArchive, run_aggregate: dict | None = None) -> dict:
|
|
|
- """Compute actual print time and accuracy for an archive.
|
|
|
|
|
-
|
|
|
|
|
- Returns dict with actual_time_seconds and time_accuracy.
|
|
|
|
|
- time_accuracy = (estimated / actual) * 100
|
|
|
|
|
- - 100% = perfect estimate
|
|
|
|
|
- - >100% = print was faster than estimated
|
|
|
|
|
- - <100% = print took longer than estimated
|
|
|
|
|
-
|
|
|
|
|
- When ``run_aggregate`` indicates the archive has more than one logged
|
|
|
|
|
- run (multi-plate file printed plate-by-plate, or reprints), both
|
|
|
|
|
- fields are suppressed: ``archive.started_at / completed_at`` reflect
|
|
|
|
|
- the LATEST run only, while ``archive.print_time_seconds`` is the
|
|
|
|
|
- whole-file estimate (post-#1593 the parser sums across plates), so
|
|
|
|
|
- comparing the two describes different scopes. The card-rendering
|
|
|
|
|
- frontend falls through to ``archive.print_time_seconds`` for the
|
|
|
|
|
- time display and hides the badge when ``time_accuracy`` is null —
|
|
|
|
|
- that's the desired "show estimate, no badge" presentation for
|
|
|
|
|
- multi-run archives (#1608). Single-run archives keep the original
|
|
|
|
|
- badge behaviour verbatim.
|
|
|
|
|
|
|
+ """The time accuracy of the archive's latest print: how its time compares
|
|
|
|
|
+ with the slicer's estimate for the plate it printed (#3375).
|
|
|
|
|
+
|
|
|
|
|
+ Returns ``actual_time_seconds``, ``time_estimate_seconds`` and
|
|
|
|
|
+ ``time_accuracy`` = estimated / actual * 100 (100 % = exact, above 100 %
|
|
|
|
|
+ = faster than estimated). The latest print is the run aggregate's; an
|
|
|
|
|
+ archive printed before runs were logged is its own one print.
|
|
|
|
|
+
|
|
|
|
|
+ Measured against that plate's estimate, not the file's, whose figures
|
|
|
|
|
+ add every plate up (#1593). Where the file's plates are not known (not
|
|
|
|
|
+ read yet, not sliced, file gone) the file's figures stand for the print:
|
|
|
|
|
+ then an archive printed more than once shows no badge, as before (#1608),
|
|
|
|
|
+ since its plates may differ. A print whose times were not measured (#3310)
|
|
|
|
|
+ or that did not complete shows none either.
|
|
|
"""
|
|
"""
|
|
|
- result: dict[str, int | float | None] = {"actual_time_seconds": None, "time_accuracy": None}
|
|
|
|
|
-
|
|
|
|
|
- # Multi-run archives: the per-run actual (started_at..completed_at on
|
|
|
|
|
- # the archive row) is incommensurable with the whole-file estimate.
|
|
|
|
|
- # Both fields are cleared so the card shows estimate + no badge.
|
|
|
|
|
- if run_aggregate and (run_aggregate.get("run_count") or 0) > 1:
|
|
|
|
|
- return result
|
|
|
|
|
- # A run whose times were not measured (#3310): its end is the slicer's
|
|
|
|
|
- # estimate or the reconnect. The card shows the estimate and no badge,
|
|
|
|
|
- # as for multi-run archives.
|
|
|
|
|
- if run_aggregate and run_aggregate.get("unmeasured_run_count"):
|
|
|
|
|
|
|
+ result: dict[str, int | float | None] = {
|
|
|
|
|
+ "actual_time_seconds": None,
|
|
|
|
|
+ "time_estimate_seconds": None,
|
|
|
|
|
+ "time_accuracy": None,
|
|
|
|
|
+ }
|
|
|
|
|
+ latest = (run_aggregate or {}).get("latest_run")
|
|
|
|
|
+ if latest is not None:
|
|
|
|
|
+ plate_id, status, unmeasured = latest.plate_id, latest.status, latest.duration_unmeasured
|
|
|
|
|
+ started_at, completed_at, duration = latest.started_at, latest.completed_at, latest.duration_seconds
|
|
|
|
|
+ else:
|
|
|
|
|
+ plate_id, status, unmeasured = archive.plate_id, archive.status, False
|
|
|
|
|
+ started_at, completed_at, duration = archive.started_at, archive.completed_at, None
|
|
|
|
|
+ actual_seconds = None
|
|
|
|
|
+ if status == "completed" and not unmeasured:
|
|
|
|
|
+ actual_seconds = duration
|
|
|
|
|
+ if not actual_seconds and started_at and completed_at:
|
|
|
|
|
+ actual_seconds = int((completed_at - started_at).total_seconds())
|
|
|
|
|
+ if actual_seconds is not None and actual_seconds <= 0:
|
|
|
|
|
+ actual_seconds = None
|
|
|
|
|
+
|
|
|
|
|
+ estimate = run_estimate(archive, plate_id)
|
|
|
|
|
+ if estimate is not None and not estimate.per_plate and ((run_aggregate or {}).get("run_count") or 0) > 1:
|
|
|
|
|
+ estimate = None
|
|
|
|
|
+ # A measured print shows its time; without an estimate of its own, no badge.
|
|
|
|
|
+ result["actual_time_seconds"] = actual_seconds
|
|
|
|
|
+ if estimate is None:
|
|
|
return result
|
|
return result
|
|
|
-
|
|
|
|
|
- if archive.started_at and archive.completed_at and archive.status == "completed":
|
|
|
|
|
- actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
|
|
|
|
|
- if actual_seconds > 0:
|
|
|
|
|
- result["actual_time_seconds"] = actual_seconds
|
|
|
|
|
-
|
|
|
|
|
- if archive.print_time_seconds and archive.print_time_seconds > 0:
|
|
|
|
|
- # Calculate accuracy as percentage
|
|
|
|
|
- accuracy = (archive.print_time_seconds / actual_seconds) * 100
|
|
|
|
|
- # Sanity check: skip unreasonable values (e.g., manually changed status)
|
|
|
|
|
- # Valid range: 5% to 500% (print took 20x longer to 5x faster than estimated)
|
|
|
|
|
- if 5 <= accuracy <= 500:
|
|
|
|
|
- result["time_accuracy"] = round(accuracy, 1)
|
|
|
|
|
-
|
|
|
|
|
|
|
+ result["time_estimate_seconds"] = estimate.seconds
|
|
|
|
|
+ if actual_seconds and estimate.seconds and estimate.seconds > 0:
|
|
|
|
|
+ accuracy = (estimate.seconds / actual_seconds) * 100
|
|
|
|
|
+ # Sanity check: skip unreasonable values (e.g., manually changed status)
|
|
|
|
|
+ # Valid range: 5% to 500% (print took 20x longer to 5x faster than estimated)
|
|
|
|
|
+ if 5 <= accuracy <= 500:
|
|
|
|
|
+ result["time_accuracy"] = round(accuracy, 1)
|
|
|
return result
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
@@ -421,6 +442,12 @@ async def _load_run_aggregates(
|
|
|
select(
|
|
select(
|
|
|
PrintLogEntry.archive_id,
|
|
PrintLogEntry.archive_id,
|
|
|
PrintLogEntry.id,
|
|
PrintLogEntry.id,
|
|
|
|
|
+ PrintLogEntry.status,
|
|
|
|
|
+ PrintLogEntry.plate_id,
|
|
|
|
|
+ PrintLogEntry.started_at,
|
|
|
|
|
+ PrintLogEntry.completed_at,
|
|
|
|
|
+ PrintLogEntry.duration_seconds,
|
|
|
|
|
+ PrintLogEntry.duration_unmeasured,
|
|
|
func.row_number()
|
|
func.row_number()
|
|
|
.over(
|
|
.over(
|
|
|
partition_by=PrintLogEntry.archive_id,
|
|
partition_by=PrintLogEntry.archive_id,
|
|
@@ -431,11 +458,12 @@ async def _load_run_aggregates(
|
|
|
.where(PrintLogEntry.archive_id.in_(archive_ids), *([scope] if scope is not None else []))
|
|
.where(PrintLogEntry.archive_id.in_(archive_ids), *([scope] if scope is not None else []))
|
|
|
.subquery()
|
|
.subquery()
|
|
|
)
|
|
)
|
|
|
- latest = await db.execute(
|
|
|
|
|
- select(ranked.c.archive_id, ranked.c.id).where(ranked.c.rn == 1).execution_options(**{INCLUDE_OPEN_RUNS: True})
|
|
|
|
|
- )
|
|
|
|
|
- for archive_id, run_id in latest.all():
|
|
|
|
|
- aggregates.setdefault(archive_id, {})["latest_run_id"] = run_id
|
|
|
|
|
|
|
+ latest = await db.execute(select(ranked).where(ranked.c.rn == 1).execution_options(**{INCLUDE_OPEN_RUNS: True}))
|
|
|
|
|
+ for row in latest.all():
|
|
|
|
|
+ aggregate = aggregates.setdefault(row.archive_id, {})
|
|
|
|
|
+ aggregate["latest_run_id"] = row.id
|
|
|
|
|
+ # What its time accuracy is measured from (#3375).
|
|
|
|
|
+ aggregate["latest_run"] = row
|
|
|
return aggregates
|
|
return aggregates
|
|
|
|
|
|
|
|
|
|
|
|
@@ -474,6 +502,8 @@ def _with_run_outcome(data: dict, run: PrintLogEntry) -> dict:
|
|
|
data["timelapse_path"] = run.timelapse_path
|
|
data["timelapse_path"] = run.timelapse_path
|
|
|
if run.photos is not None:
|
|
if run.photos is not None:
|
|
|
data["photos"] = run.photos or None
|
|
data["photos"] = run.photos or None
|
|
|
|
|
+ # The time accuracy is the latest print's (#3375), maybe another one.
|
|
|
|
|
+ data["actual_time_seconds"] = data["time_estimate_seconds"] = data["time_accuracy"] = None
|
|
|
return data
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
@@ -490,7 +520,7 @@ def _scoped(data: dict, view: ScopedView | None) -> dict:
|
|
|
# first prints, maybe on a printer outside the scope: the badge reads as
|
|
# first prints, maybe on a printer outside the scope: the badge reads as
|
|
|
# for any archive printed more than once, and the figures are the shown
|
|
# for any archive printed more than once, and the figures are the shown
|
|
|
# print's own.
|
|
# print's own.
|
|
|
- data["actual_time_seconds"] = data["time_accuracy"] = None
|
|
|
|
|
|
|
+ data["actual_time_seconds"] = data["time_estimate_seconds"] = data["time_accuracy"] = None
|
|
|
for name in ("energy_kwh", "energy_cost", "wear_cost"):
|
|
for name in ("energy_kwh", "energy_cost", "wear_cost"):
|
|
|
data[name] = getattr(view.shown, name) if view.shown is not None else None
|
|
data[name] = getattr(view.shown, name) if view.shown is not None else None
|
|
|
if view.hidden_timelapse is not None and data["timelapse_path"] == view.hidden_timelapse:
|
|
if view.hidden_timelapse is not None and data["timelapse_path"] == view.hidden_timelapse:
|
|
@@ -570,10 +600,7 @@ def archive_to_response(
|
|
|
"created_by_username": archive.created_by.username if archive.created_by else None,
|
|
"created_by_username": archive.created_by.username if archive.created_by else None,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- # Add computed time accuracy fields. ``run_aggregate`` lets
|
|
|
|
|
- # ``compute_time_accuracy`` suppress the badge for multi-run archives
|
|
|
|
|
- # where the per-run actual / whole-file estimate scopes don't match
|
|
|
|
|
- # (#1608).
|
|
|
|
|
|
|
+ # The time accuracy of the latest print, from ``run_aggregate`` (#3375).
|
|
|
accuracy_data = compute_time_accuracy(archive, run_aggregate)
|
|
accuracy_data = compute_time_accuracy(archive, run_aggregate)
|
|
|
data.update(accuracy_data)
|
|
data.update(accuracy_data)
|
|
|
|
|
|
|
@@ -1068,7 +1095,10 @@ async def list_archives_slim(
|
|
|
select(
|
|
select(
|
|
|
PrintLogEntry.printer_id,
|
|
PrintLogEntry.printer_id,
|
|
|
PrintLogEntry.print_name,
|
|
PrintLogEntry.print_name,
|
|
|
|
|
+ PrintLogEntry.plate_id,
|
|
|
PrintArchive.print_time_seconds,
|
|
PrintArchive.print_time_seconds,
|
|
|
|
|
+ PrintArchive.filament_used_grams.label("file_grams"),
|
|
|
|
|
+ PrintArchive.plate_estimates,
|
|
|
PrintLogEntry.started_at,
|
|
PrintLogEntry.started_at,
|
|
|
PrintLogEntry.completed_at,
|
|
PrintLogEntry.completed_at,
|
|
|
PrintLogEntry.duration_seconds,
|
|
PrintLogEntry.duration_seconds,
|
|
@@ -1095,7 +1125,8 @@ async def list_archives_slim(
|
|
|
{
|
|
{
|
|
|
"printer_id": r.printer_id,
|
|
"printer_id": r.printer_id,
|
|
|
"print_name": r.print_name,
|
|
"print_name": r.print_name,
|
|
|
- "print_time_seconds": r.print_time_seconds,
|
|
|
|
|
|
|
+ # The estimate for the plate it printed (#3375), not the file's.
|
|
|
|
|
+ "print_time_seconds": _event_estimate_seconds(r),
|
|
|
"actual_time_seconds": (
|
|
"actual_time_seconds": (
|
|
|
# Measured elapsed time for every status (#1390): failed /
|
|
# Measured elapsed time for every status (#1390): failed /
|
|
|
# cancelled prints still ran for some duration, and Quick
|
|
# cancelled prints still ran for some duration, and Quick
|
|
@@ -1769,39 +1800,35 @@ async def get_archive_stats(
|
|
|
PrintLogEntry.started_at,
|
|
PrintLogEntry.started_at,
|
|
|
PrintLogEntry.completed_at,
|
|
PrintLogEntry.completed_at,
|
|
|
PrintLogEntry.printer_id,
|
|
PrintLogEntry.printer_id,
|
|
|
|
|
+ PrintLogEntry.plate_id,
|
|
|
PrintArchive.print_time_seconds,
|
|
PrintArchive.print_time_seconds,
|
|
|
|
|
+ PrintArchive.filament_used_grams,
|
|
|
|
|
+ PrintArchive.plate_estimates,
|
|
|
)
|
|
)
|
|
|
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
|
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
|
|
.where(
|
|
.where(
|
|
|
PrintLogEntry.status == "completed",
|
|
PrintLogEntry.status == "completed",
|
|
|
- PrintArchive.print_time_seconds.isnot(None),
|
|
|
|
|
# A run whose times were not measured is no sample (#3310).
|
|
# A run whose times were not measured is no sample (#3310).
|
|
|
PrintLogEntry.duration_unmeasured.is_not(True),
|
|
PrintLogEntry.duration_unmeasured.is_not(True),
|
|
|
*base_conditions,
|
|
*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.
|
|
|
|
|
|
|
+ # Each run against the estimate of the plate it printed (#3375): the
|
|
|
|
|
+ # archive's adds every plate up (#1593). Where the file's plates are not
|
|
|
|
|
+ # known, the file's estimate, which for a multi-plate file printed plate
|
|
|
|
|
+ # by plate is about N x 100 %. Clamped to [50 %, 200 %] so the average
|
|
|
|
|
+ # reflects slicer-vs-reality drift, not those or one-off outliers
|
|
|
|
|
+ # (manual interventions, purge waste).
|
|
|
_ACCURACY_BAND_LO = 50.0
|
|
_ACCURACY_BAND_LO = 50.0
|
|
|
_ACCURACY_BAND_HI = 200.0
|
|
_ACCURACY_BAND_HI = 200.0
|
|
|
average_accuracy = None
|
|
average_accuracy = None
|
|
|
accuracy_by_printer: dict[str, float] = {}
|
|
accuracy_by_printer: dict[str, float] = {}
|
|
|
accuracies: list[float] = []
|
|
accuracies: list[float] = []
|
|
|
printer_accuracies: dict[str, list[float]] = {}
|
|
printer_accuracies: dict[str, list[float]] = {}
|
|
|
- for duration_seconds, started_at, completed_at, run_printer_id, estimate_seconds in accuracy_rows.all():
|
|
|
|
|
|
|
+ for row in accuracy_rows.all():
|
|
|
|
|
+ duration_seconds, started_at, completed_at, run_printer_id = row[:4]
|
|
|
|
|
+ estimate = run_estimate(row, row.plate_id)
|
|
|
|
|
+ estimate_seconds = estimate.seconds if estimate is not None else None
|
|
|
actual_seconds = duration_seconds
|
|
actual_seconds = duration_seconds
|
|
|
if not actual_seconds and started_at and completed_at:
|
|
if not actual_seconds and started_at and completed_at:
|
|
|
elapsed = (completed_at - started_at).total_seconds()
|
|
elapsed = (completed_at - started_at).total_seconds()
|
|
@@ -2262,6 +2289,11 @@ async def list_archive_runs(
|
|
|
for item in items:
|
|
for item in items:
|
|
|
if item.photos is not None:
|
|
if item.photos is not None:
|
|
|
item.photos = [p for p in item.photos if p in archive_photos] or None
|
|
item.photos = [p for p in item.photos if p in archive_photos] or None
|
|
|
|
|
+ # What the slicer gave the plate it printed (#3375); the file's
|
|
|
|
|
+ # figures only for its one print when its plates are not known.
|
|
|
|
|
+ estimate = run_estimate(archive, item.plate_id)
|
|
|
|
|
+ if estimate is not None and (estimate.per_plate or len(items) == 1):
|
|
|
|
|
+ item.estimate_seconds, item.estimate_grams = estimate.seconds, estimate.grams
|
|
|
return PrintLogResponse(items=items, total=len(items))
|
|
return PrintLogResponse(items=items, total=len(items))
|
|
|
|
|
|
|
|
|
|
|