A toner rate needs days behind it, not just readings
Some checks failed
CI / backend (push) Failing after 7m13s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m14s

Reported from the floor: two printers showing 81 and 83 percent, both forecast
to run out in a fortnight. That is a rate near 5.8 percent a day on a cartridge
barely touched.

burn_rate required four readings and a 2 point drop, and checked only that some
time had passed - not how much. Supply items are commonly polled every few
minutes, so four readings can span a quarter of an hour, and a 2 point drop
across fifteen minutes extrapolates to nearly 200 percent a day. The report then
sorted that confident wrong number into "soon", next to cartridges that really
are about to run out. It did not look broken; it looked urgent.

A rate now needs two days behind it. Two days is the smallest span that survives
a printer's daily rhythm, so one heavy morning does not become the whole picture.
Below that the answer is "not enough history yet", which the report already has
a home for: band() returns None and the row lands in the "No estimate yet"
section with its reason shown, rather than competing for attention with real
urgency.

This is the third face of the same fault. A phantom replacement truncated the
run - "2 replacements in 2.3 days" was the same printer saying so - and the rate
was then fitted to whatever short stub remained. The near-full rule and the dip
filter stop the truncation; this stops a stub from producing a number at all.
This commit is contained in:
cproudlock
2026-08-20 17:23:34 -04:00
parent 8b9b9363ee
commit 0c0c7be439
2 changed files with 38 additions and 2 deletions

View File

@@ -37,6 +37,18 @@ NEW_CARTRIDGE_LEVEL = 80
# through a coarse gauge can "prove" any rate at all. # through a coarse gauge can "prove" any rate at all.
MIN_POINTS_FOR_ESTIMATE = 4 MIN_POINTS_FOR_ESTIMATE = 4
# A rate needs TIME behind it, not just readings. Supply items are often polled
# every few minutes, so four readings can span a quarter of an hour - and a 2
# point drop across fifteen minutes extrapolates to nearly 200 percent a day,
# which is how a cartridge sitting at 82 percent gets forecast to run out in a
# fortnight. The history window can be short for the same reason: the fetch
# keeps the newest rows up to a per-item cap, so a fast-polled item returns days
# rather than the ninety asked for.
#
# Two days is the smallest span that survives a printer's daily rhythm - a
# single heavy morning does not become the whole picture.
MIN_DAYS_FOR_ESTIMATE = 2
# Many printers report in 10% steps, so a fortnight can pass on one plateau. # Many printers report in 10% steps, so a fortnight can pass on one plateau.
# Without a minimum observed drop the slope reads as zero and the forecast # Without a minimum observed drop the slope reads as zero and the forecast
# says "never", which is worse than saying nothing. # says "never", which is worse than saying nothing.
@@ -201,7 +213,7 @@ def burn_rate(points):
if len(points) < MIN_POINTS_FOR_ESTIMATE: if len(points) < MIN_POINTS_FOR_ESTIMATE:
return None return None
total_days = (points[-1][0] - points[0][0]).total_seconds() / 86400 total_days = (points[-1][0] - points[0][0]).total_seconds() / 86400
if total_days <= 0: if total_days < MIN_DAYS_FOR_ESTIMATE:
return None return None
# The overall drop still gates the estimate: a gauge sitting on one plateau # The overall drop still gates the estimate: a gauge sitting on one plateau
# has not proved anything yet, whatever the intervals say. # has not proved anything yet, whatever the intervals say.
@@ -290,7 +302,8 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
if rate is None: if rate is None:
# Say which of the three it is; "no estimate" alone invites a bug report. # Say which of the three it is; "no estimate" alone invites a bug report.
if len(run) < MIN_POINTS_FOR_ESTIMATE: basis = result.get('basisdays') or 0
if len(run) < MIN_POINTS_FOR_ESTIMATE or basis < MIN_DAYS_FOR_ESTIMATE:
result['reason'] = ('replaced recently' if replacements result['reason'] = ('replaced recently' if replacements
else 'not enough history yet') else 'not enough history yet')
else: else:

View File

@@ -339,3 +339,26 @@ def test_a_real_near_empty_reading_before_a_swap_is_kept():
points = normalise(series([40, 30, 5, 100, 95, 90])) points = normalise(series([40, 30, 5, 100, 95, 90]))
assert 5.0 in [level for _, level in points] assert 5.0 in [level for _, level in points]
assert len(find_replacements(points)) == 1 assert len(find_replacements(points)) == 1
def test_minutes_of_readings_do_not_forecast_weeks():
"""Supply items are often polled every few minutes.
Four readings a quarter of an hour apart, with a 2 point drop between the
ends, used to extrapolate to nearly 200 percent a day - so a cartridge
sitting at 82 percent was forecast to run out in a fortnight. A rate needs
time behind it, and without it the honest answer is no estimate.
"""
minutes = 5
detail = analyse(series([84, 83, 83, 82], hours=minutes / 60))
assert detail['burnrateperday'] is None
assert detail['daysleft'] is None
assert detail['reason'] == 'not enough history yet'
assert band(detail['daysleft']) is None # lands in "No estimate yet"
def test_a_run_with_enough_days_still_estimates():
"""The guard must not silence a genuinely slow, genuinely long run."""
detail = analyse(series([84, 83, 82, 81, 80, 79]))
assert detail['burnrateperday'] == 1.0
assert detail['daysleft'] == 79