Fix what the last round of device fixes broke, and two dips it missed
A review ofd60ed60and8b9b936found five things. Three were introduced by those commits. REFUSING A NAMED DEVICE MADE A MARKER PC CLAIM ITS OPERATION. _sync_partmarker returned [] both for "not a marker PC" and for "a marker PC that linked nothing", and the caller reads [] as the first - so a bay whose asset-id.txt named something unresolvable fell through to the ordinary machine link, and for a marker PC the machine number IS the operation. It took an active link to a record that can only have one holder while several markers share it, and the warning said "not linked". The previous behaviour minted a twin; this traded that for a contested operation. None now means "not a marker PC" and is the only answer that lets the machine link run; the response normalises it away so the API shape is unchanged. A DORMANT CHALLENGER WAS PROMOTED BY DELETING A FILE. Recording a challenger dormant leaves a row that the next cycle finds as `reuse` and reactivated with no incumbent check - so a second PC took a live device by its enrollment file becoming unreadable. Both reuse branches re-check incumbency now, which is what _sync_machine_link always did. AN INCUMBENT UNDER ANOTHER COLLECTOR LABEL WAS INVISIBLE. Incumbency was queried on our own label, but on a CMM the instrument IS the reported bay, so the incumbent's link is the machine sync's row. A second PC naming that instrument found no incumbent and linked actively: two live holders of one instrument, each invisible to the other. Incumbency now counts any collector-owned label. A row made BY HAND carries none of them and is still excluded - a person's link is not the collector's to archive. THE NETWORK FORM LOCKED OUT THE ROWS IT NEEDED TO FIX. Asset number is disabled while editing, correctly, but the payload is built in script so the blank was still sent - and the new server-side guard rejects it. A device with no asset number could not be saved at all, and the field could not be typed into. It now unlocks only for a record that loaded without one, with a hint saying why. "2 in 4.0d" WAS THE LABEL LYING. Replacements are counted across the whole history window; basisdays is only how long the current cartridge has been in. Joining them with "in" claimed two changes inside four days - the exact shape reported as unbelievable, except here the data was right. Now "2, this one 4.0d". Two toner dips the same review found: A MULTI-POLL OUTAGE STILL MINTED A PHANTOM SWAP. Only single readings were dropped, so 90, 0, 0, 90 survived and 0 -> 90 scored as a change. Dips of any length are handled now. One bad sample stays a candidate whatever the polling cadence, because a reading is an instant; several consecutive low ones only count as one outage when they are close together, since days at zero is a real empty period. That time bound also separates an outage from a swap, ordinary consumption, and a second swap, which have the same shape in levels alone. THE DIP FILTER ATE REAL SWAPS OF NEARLY-FULL CARTRIDGES. Recovery was tested with an absolute difference, so 95 then 5 then 100 read as a recovery because 100 and 95 are close, and the swap evidence was deleted. Toner only falls: a recovery comes back at or BELOW where it left, a new cartridge comes back higher. The review also proved by reverting each feature that the previous tests did not pin the median burn rate or the near-full rule - both passed with the bug restored. Verified by the same method that all four toner behaviours now fail when reverted, and the burst assertion is tight enough to tell 0.2 from 0.88.
This commit is contained in:
@@ -33,6 +33,20 @@ REPLACEMENT_RISE = 10
|
||||
# while a phantom swap resets the run and throws the estimate away entirely.
|
||||
NEW_CARTRIDGE_LEVEL = 80
|
||||
|
||||
# How far ABOVE its previous level a reading may come back and still count as a
|
||||
# recovery rather than a new cartridge. Small, because it exists for gauge
|
||||
# noise: a real swap returns near full, well past this.
|
||||
RECOVERY_TOLERANCE = 2
|
||||
|
||||
# How long a dip may last and still be a bad reading rather than real use.
|
||||
# Levels alone cannot separate the two: a swap to full, ordinary consumption,
|
||||
# then another swap has the same SHAPE as an outage that recovers. What differs
|
||||
# is elapsed time. A supply out of the machine, a door open or a bad poll spans
|
||||
# minutes to an hour or two at the few-minute polling these items use; a level
|
||||
# that stays down for days is genuinely down, and deleting those readings would
|
||||
# hide a real empty period.
|
||||
MAX_DIP_HOURS = 6
|
||||
|
||||
# Below this many readings a slope is arithmetic, not evidence. Two points
|
||||
# through a coarse gauge can "prove" any rate at all.
|
||||
MIN_POINTS_FOR_ESTIMATE = 4
|
||||
@@ -92,8 +106,23 @@ def normalise(points):
|
||||
return drop_spikes(out)
|
||||
|
||||
|
||||
def _recovered(previous, following, tolerance=RECOVERY_TOLERANCE):
|
||||
"""Did the level come BACK to where it was, rather than up to a new one?
|
||||
|
||||
Toner only falls, so a dip that recovers returns to at or below the level it
|
||||
left - consumption carried on while the reading was junk. A cartridge that
|
||||
was CHANGED comes back HIGHER than the level before the dip.
|
||||
|
||||
That asymmetry is the whole test. Comparing the absolute difference instead
|
||||
treated a genuine swap of a nearly-full cartridge (95, then 5, then 100) as
|
||||
a recovery and deleted the evidence, because 100 and 95 are close. The small
|
||||
upward tolerance is for gauge noise, not for swaps.
|
||||
"""
|
||||
return following <= previous + tolerance
|
||||
|
||||
|
||||
def drop_spikes(points, rise=REPLACEMENT_RISE):
|
||||
"""Remove one-reading dips that RECOVER to where they came from.
|
||||
"""Remove dips that RECOVER to where they came from, however long they run.
|
||||
|
||||
A single reading far below both neighbours, then a recovery, is a big
|
||||
upward step that scores as a cartridge change. That is how a cartridge
|
||||
@@ -108,22 +137,45 @@ def drop_spikes(points, rise=REPLACEMENT_RISE):
|
||||
The filter keys on SHAPE rather than cause, which is why it holds for all of
|
||||
them: a level that comes back to where it was did not get a new cartridge.
|
||||
|
||||
Only a dip that comes BACK to roughly its previous level is removed. A
|
||||
genuine near-empty reading before a swap (30, 5, 100) does not recover - it
|
||||
jumps to full - so it is kept, and the swap after it still counts.
|
||||
ANY LENGTH, not just one reading. At the few-minute polling these items
|
||||
often use, a door left open or a supply out of the machine spans several
|
||||
polls, and a filter that only removed single readings left the original
|
||||
failure in place for every cartridge above NEW_CARTRIDGE_LEVEL.
|
||||
|
||||
A genuine near-empty reading before a swap (30, 5, 100) does not recover -
|
||||
it comes back HIGHER than 30 - so it is kept and the swap still counts.
|
||||
"""
|
||||
if len(points) < 3:
|
||||
return points
|
||||
out = [points[0]]
|
||||
for index in range(1, len(points) - 1):
|
||||
index = 1
|
||||
while index < len(points) - 1:
|
||||
previous = points[index - 1][1]
|
||||
current = points[index][1]
|
||||
following = points[index + 1][1]
|
||||
dipped = (previous - current) >= rise and (following - current) >= rise
|
||||
recovered = abs(following - previous) <= rise
|
||||
if dipped and recovered:
|
||||
if (previous - points[index][1]) < rise:
|
||||
out.append(points[index])
|
||||
index += 1
|
||||
continue
|
||||
out.append(points[index])
|
||||
# A dip starts here. Take every consecutive reading that stays down.
|
||||
end = index
|
||||
while end < len(points) - 1 and (previous - points[end][1]) >= rise:
|
||||
end += 1
|
||||
following = points[end][1]
|
||||
# ONE bad sample is a candidate whatever the cadence: the reading is an
|
||||
# instant, and the gap to its neighbours says nothing about how long the
|
||||
# supply was actually out. SEVERAL consecutive low readings only count
|
||||
# as one outage if they are close together - spread over days they are a
|
||||
# real absence, and they also have the same shape as a swap, ordinary
|
||||
# consumption, then another swap.
|
||||
spanhours = (points[end][0] - points[index - 1][0]).total_seconds() / 3600
|
||||
brief = (end - index) == 1 or spanhours <= MAX_DIP_HOURS
|
||||
if (brief
|
||||
and (following - points[end - 1][1]) >= rise
|
||||
and _recovered(previous, following)):
|
||||
index = end # drop the whole stretch
|
||||
continue
|
||||
for keep in range(index, end):
|
||||
out.append(points[keep])
|
||||
index = end
|
||||
out.append(points[-1])
|
||||
return out
|
||||
|
||||
|
||||
Reference in New Issue
Block a user