Toner: count cartridge changes that happened, and rate a burst as one burst
Some checks failed
CI / backend (push) Failing after 7m10s
CI / naming (push) Failing after 7m10s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled

Two things reported from the floor, one cause each.

"5 CHANGES IN 90 DAYS, THAT'S HARD TO BELIEVE." It was. A replacement was any
+10 rise between readings, with no check on where it landed, so two shapes that
are not swaps scored as swaps: a supply reading 0 or near-0 while it was out of
the machine and then reading normally again, and a coarse gauge ticking back up
after a reseat or a power cycle.

A swap must now also LAND near full, because that is what a new cartridge reads,
and a single dip that RECOVERS to roughly where it came from is dropped before
anything looks at it. The dip filter keys on shape rather than cause, which is
why it holds for all of them - a supply pulled out to be shaken, a door open
mid-poll, or a site whose preprocessing maps the Printer MIB's unknown
sentinels onto 0. It is NOT a Zabbix timeout: an item that does not answer
records nothing rather than writing a zero. A genuine near-empty reading before
a real swap does not recover, it jumps to full, so it survives and its swap
still counts.

find_replacements and current_run now read one predicate. When they disagreed, a
phantom rise reset the run and threw away the history the estimate needed - so
the bad count was quietly damaging the rate as well, which is why both were
wrong at once. Expect replacement counts to FALL and per-cartridge history to
lengthen.

A BURST BIASED THE RATE FOR THE LIFE OF THE CARTRIDGE. The rate was the slope
between the first and last reading of the run, and two endpoints cannot tell
"steady" from "burst then stopped". A cartridge that lost 20 percent in two days
and then barely moved for a month read as 0.83 percent/day forever after, so the
report kept promising it would run out long after printing slowed. It is now the
median of the per-interval rates: the burst is one interval among many rather
than one of two points. Rising intervals are dropped as noise; flat ones stay in
at zero, because a cartridge that did not move is real information. If every
interval is flat or rising yet the run dropped overall, it falls back to the
whole-run slope rather than reporting nothing.

Where the intervals disagree by 5x or more the rate carries a marker and an
explanation on hover. The number is still the best estimate available; the flag
stops it reading as a measurement.

The "Changed" column is "Replacements", and its cell says "2 in 90d" rather than
"2 / 90d", which was read as a date, a ratio and a version number.
This commit is contained in:
cproudlock
2026-08-20 16:11:27 -04:00
parent d60ed602a1
commit 8b9b9363ee
3 changed files with 221 additions and 16 deletions

View File

@@ -267,3 +267,75 @@ def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
assert 'not configured' in body['reason']
assert body['cartridges'] == []
assert body['orderlist'] == []
# --------------------------------------------------------------------------
# Noise that used to read as cartridge changes, and bursts that used to bias
# the rate for the life of the cartridge. Both were reported from the floor:
# "5 changes in 90 days, that's hard to believe", and a cartridge that dropped
# 20 percent in two days then barely moved.
# --------------------------------------------------------------------------
def test_a_poll_returning_zero_is_not_a_cartridge_change():
"""0 then a real level is an SNMP error or a calibrating printer.
The rise is +60, which cleared the old threshold on its own. It does not
land near full, so it is not a swap.
"""
points = normalise(series([70, 60, 0, 60, 55, 50]))
assert find_replacements(points) == []
def test_a_gauge_ticking_back_up_mid_range_is_not_a_change():
"""A coarse gauge after a reseat or a power cycle. Lands at 55, not full."""
points = normalise(series([70, 60, 45, 55, 50, 45]))
assert find_replacements(points) == []
def test_a_real_swap_is_still_counted():
"""Near-empty to near-full. The shape a cartridge change actually makes."""
points = normalise(series([30, 15, 5, 100, 95, 90]))
assert len(find_replacements(points)) == 1
def test_the_run_starts_at_the_real_swap_not_at_the_noise():
"""current_run and find_replacements must agree on what a change is.
They read the same predicate now; when they did not, a phantom rise reset
the run and threw away the history the estimate needed.
"""
points = normalise(series([90, 0, 85, 80, 75, 70]))
assert find_replacements(points) == []
assert len(current_run(points)) == len(points)
def test_an_early_burst_does_not_dominate_the_rate_forever():
"""20 percent in two days, then a month of almost nothing.
The endpoint slope reads the burst forever: (100-75)/30 = 0.83 %/day, so
the report keeps promising the cartridge runs out long after printing
stopped. The median sees one fast interval among many quiet ones.
"""
levels = [100, 90, 80] + [80 - i * 0.2 for i in range(1, 28)]
rate = burn_rate(normalise(series(levels)))
assert rate is not None
assert rate < 1.0, rate
detail = analyse(series(levels))
assert detail['rateunstable'] is True
def test_a_steady_cartridge_is_not_flagged_unstable():
detail = analyse(series([100, 95, 90, 85, 80, 75, 70]))
assert detail['burnrateperday'] == 5.0
assert detail['rateunstable'] is False
def test_a_real_near_empty_reading_before_a_swap_is_kept():
"""30, 5, 100 is a cartridge run to the end and changed - not a spike.
The dip filter must not eat it: the 5 does not RECOVER to 30, it jumps to
full, which is the shape of a swap rather than of a bad poll.
"""
points = normalise(series([40, 30, 5, 100, 95, 90]))
assert 5.0 in [level for _, level in points]
assert len(find_replacements(points)) == 1