mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-10-21 23:39:01 +01:00
147 lines
5.4 KiB
Python
147 lines
5.4 KiB
Python
"""Shared output-shaping helpers enforcing the spec's data-correction rules.
|
|
|
|
Every rule here traces to a verified finding in "Bambu-Run MCP Tool Surface"
|
|
(the "Data Reality Findings" section) -- not a general defensive-programming
|
|
habit. Do not relax these without re-checking against real data.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import statistics
|
|
|
|
STALE_THRESHOLD_SECONDS = 120
|
|
LOW_REMAINING_THRESHOLD = 20
|
|
|
|
|
|
def freshness(timestamp, now):
|
|
"""(data_age_seconds, stale) for a reading timestamp relative to `now`.
|
|
|
|
`now` is passed in rather than computed here so a caller building a
|
|
multi-field response reuses one `timezone.now()` call throughout,
|
|
keeping `generated_at` and every `data_age_seconds` mutually consistent.
|
|
"""
|
|
age_seconds = (now - timestamp).total_seconds()
|
|
return {
|
|
"data_age_seconds": int(age_seconds),
|
|
"stale": age_seconds > STALE_THRESHOLD_SECONDS,
|
|
}
|
|
|
|
|
|
def remaining_status(percent):
|
|
"""Classify one raw remaining-percent reading, honoring the -1 sentinel.
|
|
|
|
Returns (value, status): value is percent-or-None; status is one of
|
|
"unread" | "empty" | "low" | "ok". -1 and None both mean "no RFID
|
|
reading yet" -- the spec rules out -1 meaning "low" (jobs 62/63/70/77
|
|
show start=-1% followed by end=90-100%, which a genuinely low reading
|
|
could not do).
|
|
"""
|
|
if percent is None or percent == -1:
|
|
return None, "unread"
|
|
percent = int(round(percent))
|
|
if percent <= 0:
|
|
return 0, "empty"
|
|
if percent <= LOW_REMAINING_THRESHOLD:
|
|
return percent, "low"
|
|
return percent, "ok"
|
|
|
|
|
|
def smoothed_remaining_percent(readings):
|
|
"""Median of the 5 most recent remain_percent readings for one tray.
|
|
|
|
`readings` is most-recent-first. -1 sentinel readings are excluded
|
|
before the median, not treated as zero (spec: AMS remain_percent swings
|
|
22-35% mid-print on real RFID noise; median-of-5 rejects single-sample
|
|
spikes while still tracking genuine change).
|
|
"""
|
|
recent = readings[:5]
|
|
valid = [r for r in recent if r is not None and r != -1]
|
|
if not valid:
|
|
return remaining_status(-1 if recent else None)
|
|
return remaining_status(statistics.median(valid))
|
|
|
|
|
|
def classify_alerts(code_counts, total_rows):
|
|
"""Turn {(attr, code): occurrences} window counts into the alerts array.
|
|
|
|
Frequency heuristic (spec: HMS codes are stateful, not event-based --
|
|
the two most common codes alone accounted for over 80% of all alert
|
|
occurrences in the verified data, in long unbroken runs, not isolated
|
|
events): a code present in >20% of window rows is "persistent" (a
|
|
likely standing advisory, surfaced once with a count); <5% is
|
|
"transient" (possibly actionable). The 5-20% band is omitted rather
|
|
than guessed at. severity is always "unknown" -- nothing in Bambu-Run
|
|
decodes these codes yet; do not assert "fine" or "faulty" from them.
|
|
"""
|
|
alerts = []
|
|
for (attr, code), count in code_counts.items():
|
|
ratio = count / total_rows if total_rows else 0
|
|
if ratio > 0.20:
|
|
kind = "persistent"
|
|
elif ratio < 0.05:
|
|
kind = "transient"
|
|
else:
|
|
continue
|
|
alerts.append({
|
|
"attr": attr,
|
|
"code": code,
|
|
"kind": kind,
|
|
"occurrences": count,
|
|
"severity": "unknown",
|
|
})
|
|
return alerts
|
|
|
|
|
|
def display_name(design_title, plate_title, project_name):
|
|
"""design_title -> plate_title -> project_name fallback.
|
|
|
|
Spec finding: design_title is empty on 4 of 5 recent BambuCloudTask rows.
|
|
"""
|
|
return design_title or plate_title or project_name
|
|
|
|
|
|
def normalize_consumed(starting_percent, ending_percent, initial_weight_grams):
|
|
"""FilamentUsage consumption, honest about the -1 sentinel and RFID jitter.
|
|
|
|
Returns (consumed_percent, consumed_grams, data_quality). data_quality is
|
|
"ok" or "unreliable". A negative or sentinel-derived result becomes
|
|
(None, None, "unreliable") -- never a negative figure, never a
|
|
fabricated zero (spec: 24/269 usage rows go negative; a -1 sentinel
|
|
subtracted as a real value produces e.g. consumed=-101%).
|
|
"""
|
|
if (
|
|
starting_percent is None or starting_percent == -1
|
|
or ending_percent is None or ending_percent == -1
|
|
):
|
|
return None, None, "unreliable"
|
|
consumed_percent = starting_percent - ending_percent
|
|
if consumed_percent < 0:
|
|
return None, None, "unreliable"
|
|
consumed_grams = None
|
|
if initial_weight_grams:
|
|
consumed_grams = int(initial_weight_grams * (consumed_percent / 100.0))
|
|
return consumed_percent, consumed_grams, "ok"
|
|
|
|
|
|
def resolve_printer(printer):
|
|
"""Resolve an id, exact name, or None to a Printer queryset.
|
|
|
|
None returns all active printers. Raises Printer.DoesNotExist if a
|
|
specific id/name was given and nothing matched -- MCP tool wrappers let
|
|
this propagate as a clear tool error rather than returning empty data
|
|
that looks like "no printers exist".
|
|
"""
|
|
from bambu_run.models import Printer
|
|
|
|
if printer is None:
|
|
return Printer.objects.filter(is_active=True)
|
|
|
|
if isinstance(printer, int) or (isinstance(printer, str) and printer.isdigit()):
|
|
by_id = Printer.objects.filter(pk=int(printer))
|
|
if by_id.exists():
|
|
return by_id
|
|
|
|
by_name = Printer.objects.filter(name=printer)
|
|
if not by_name.exists():
|
|
raise Printer.DoesNotExist(f"No printer matching {printer!r}")
|
|
return by_name
|