Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions tests/test_grade.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,23 @@ def test_a_batch_timed_in_seconds_delivers_half_when_the_pump_is_re_rated(tmp_pa
assert code == 1 and report["verdict"] == "FAIL"
assert "dose2.on_the_number" in failed_ids(report)
first, second = report["evidence"]["batches"]
# Right once: the stopwatch answer is calibrated, and its first batch lands.
assert "dose1.on_the_number" not in failed_ids(report)
# The lesson, and the only part of it that is a fact about the controller
# rather than about the machine it ran on: re-rate the pump and a batch
# ended on seconds delivers about half, while the pot did not move.
assert second["delivered_L"] < first["delivered_L"] * 0.65
assert second["delivered_L"] > first["delivered_L"] * 0.35

# Deliberately NOT asserted: that the first batch lands inside tolerance.
#
# It does on an idle machine and it did here for a while. But a stopwatch
# with no taper cuts off at a scan boundary, so it overshoots by up to one
# scan's worth of delivery -- 2 L/s at the rated flow -- and how coarse the
# scans get is a fact about how busy the machine is. Asserting it made this
# test fail on Linux CI under a full suite and pass alone, which reads as a
# flaky grader and is really a claim that was never the controller's to
# make. The overshoot is also the thing the scene teaches, so pinning it
# would pin the lesson to a machine.
assert first["delivered_L"] >= second["delivered_L"]
assert any("ends on seconds cannot see that" in line
for line in report["feedback"])

Expand Down
24 changes: 22 additions & 2 deletions tools/grade.py
Original file line number Diff line number Diff line change
Expand Up @@ -3429,9 +3429,29 @@ def lamps(self) -> dict:


async def run_scan(bus, stop: asyncio.Event, body, period: float = SCAN) -> None:
"""Call `body(dt)` on a fixed scan until told to stop."""
"""Call `body(dt)` on a fixed scan until told to stop.

`dt` is REAL elapsed time, not the nominal period. Passing the period is
the mistake AGENTS.md gotcha 3 records for the engines themselves --
"stepping once per sleep(tick_ms) runs the sim slow" -- arriving here
instead. A scan takes `period` plus however long the body and the event
loop took, and the plant advances by that whole amount because it runs on
its own wall-clock accumulator. A controller counting only `period`
therefore under-counts elapsed time, by nothing at all on an idle machine
and by a lot on a busy one.

That is not academic: the stopwatch reference for batch-dosing computes a
cut-off in seconds, and under-counting made it run the pump past the
number -- 23.8 L against a 22 L pot on Linux CI, where the same code
lands 22.0 L here. Nine graded tests failed that way, and every one of
them read as a flaky grader rather than as a controller whose clock was
wrong.
"""
last = time.perf_counter()
while not stop.is_set():
await body(period)
now = time.perf_counter()
dt, last = now - last, now
await body(dt)
await asyncio.sleep(period)


Expand Down
Loading