Fix what the last round of device fixes broke, and two dips it missed

This commit is contained in:
2026-08-21 08:50:00 -04:00
parent 4f0399b8ac
commit 5a58d33623
8 changed files with 517 additions and 26 deletions

View File

@@ -211,6 +211,11 @@ def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
# and the test passed because it only checked that PM-TYPO was not created.
assert Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None
assert _controlled('FMARK500', 'collector:partmarker') == []
# AND it must not fall through to claiming the OPERATION directly. A marker
# PC reports the operation as its machine number, so "link nothing" that
# let the ordinary machine link run traded a phantom marker for a contested
# operation - several marker PCs share one, and it holds a single link.
assert _controlled('FMARK500', 'collector:machine') == []
def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
@@ -222,6 +227,7 @@ def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
machinenumber='0617', deviceid='PLAIN-MACHINE')
assert resp.status_code in (200, 201)
assert _controlled('FMARK600', 'collector:partmarker') == []
assert _controlled('FMARK600', 'collector:machine') == []
assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PLAIN-MACHINE' in warnings
@@ -267,3 +273,54 @@ def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
deviceid='MT-9003', measuringtoolid='MT-9004')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']
def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
client, db, rig, collector_key):
"""Deleting asset-id.txt must not hand a device to the challenger.
The dormant row this feature creates is found as `reuse` on the next cycle.
Reactivating it unconditionally meant a second PC took a live device by
losing a file - no warning, two active holders.
"""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK200', 'collector:partmarker') == []
# Next cycle: the file is gone, so no deviceid is sent at all.
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
machinenumber='0613')
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
assert _controlled('FMARK200', 'collector:partmarker') == []
def test_an_incumbent_under_another_collector_label_is_still_seen(
client, db, rig, collector_key):
"""On a CMM the instrument IS the bay, so the incumbent's link is the
machine sync's row under collector:machine. Counting only our own label
left two live holders of one instrument, each invisible to the other.
"""
_tool(db, 'CMM4')
# A LIVE incumbent: it reported through the collector, so it has a computer
# row and a recent check-in. A bare asset would correctly be treated as
# yielded, since something that cannot report cannot be holding anything.
_report(client, collector_key, 'WCMM100',
pctype='gea-shopfloor-keyence', deviceid='CMM4')
# Relabel its link to the machine sync's origin, which is the shape a CMM
# really has: there the instrument IS the reported bay.
pc = Asset.query.filter(Asset.assetnumber.ilike('WCMM100')).first()
held = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).first()
held.label = 'collector:machine'
db.session.commit()
resp = _report(client, collector_key, 'WCMM200',
pctype='gea-shopfloor-keyence', deviceid='CMM4')
assert _controlled('WCMM200', 'collector:measuringtool') == []
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'CMM4' in warnings

View File

@@ -319,7 +319,9 @@ def test_an_early_burst_does_not_dominate_the_rate_forever():
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
# The old endpoint slope gives ~0.88 for this series, so a threshold of 1.0
# passed with the bug still in. The median gives ~0.2.
assert rate < 0.5, rate
detail = analyse(series(levels))
assert detail['rateunstable'] is True
@@ -362,3 +364,64 @@ def test_a_run_with_enough_days_still_estimates():
detail = analyse(series([84, 83, 82, 81, 80, 79]))
assert detail['burnrateperday'] == 1.0
assert detail['daysleft'] == 79
def test_a_multi_poll_outage_is_one_dip_not_a_cartridge_change():
"""A door open or a supply out of the machine spans several polls.
These items are often polled every few minutes, so an outage covers more
than one reading. Removing only SINGLE readings left the original failure
in place for any cartridge above NEW_CARTRIDGE_LEVEL: 0 -> 90 clears the
rise and lands near full, so it scored as a swap.
"""
points = normalise(series([90, 0, 0, 90, 88, 86], hours=5 / 60))
assert [level for _, level in points] == [90.0, 90.0, 88.0, 86.0]
assert find_replacements(points) == []
def test_a_long_absence_is_not_deleted_as_noise():
"""Days at zero is a real empty period, not a bad poll.
Only a BRIEF multi-reading dip is removed. Deleting a level that stayed
down for days would hide exactly the outage someone needs to see.
"""
points = normalise(series([90, 0, 0, 90, 88, 86]))
assert 0.0 in [level for _, level in points]
def test_a_swap_of_a_nearly_full_cartridge_is_not_eaten_by_the_dip_filter():
"""95, then 5, then 100 is a swap, not a dip that recovered.
Comparing the absolute difference treated it as a recovery, because 100 and
95 are close, and deleted the evidence: the run then spanned two cartridges
and the replacement went uncounted. Toner only falls, so a recovery comes
back at or BELOW where it left - a new cartridge comes back higher.
"""
points = normalise(series([98, 95, 5, 100, 96, 92]))
assert 5.0 in [level for _, level in points]
assert len(find_replacements(points)) == 1
def test_consumption_between_two_swaps_is_not_mistaken_for_a_dip():
"""Swap to full, print for days, swap again: same shape, different thing.
The readings between the swaps are far below both bounding levels, which is
what a dip looks like. What separates them is elapsed time.
"""
points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70]))
assert [level for _, level in points] == [80.0, 60.0, 40.0, 20.0,
100.0, 80.0, 60.0, 95.0, 70.0]
assert len(find_replacements(points)) == 2
def test_a_rise_that_stops_mid_range_is_not_a_cartridge_change():
"""Isolates the near-full rule from the dip filter.
50 -> 75 clears the rise and is NOT a recovering dip (75 comes back higher
than the 60 before it), so the dip filter leaves it alone. Only the rule
that a swap must LAND near full rejects it. A new cartridge does not read
75 percent.
"""
points = normalise(series([60, 50, 75, 73, 71]))
assert 50.0 in [level for _, level in points]
assert find_replacements(points) == []