~/blogempty-detections-and-offline-detectors.md
cchu@nycu:~/blog$ cat empty-detections-and-offline-detectors.md
2026.09.196 min[edge-ml][fault-tolerance][python][testing]

Empty Detections and Offline Detectors

A local Fugleramme outage probe checks the difference between a quiet bird detector and a broken connection, including a Windows startup failure.

I disconnected a fake bird detector after Fugleramme rendered one page. The next two iterations kept the PNG byte for byte. I had set the API cache lifetime to zero, so a cached detection could not hide the outage.

That small test covers a useful part of an ML appliance: the code between a prediction service and the thing a person sees. A classifier can return no detections. A broken connection can return no answer. If the application handles both as an empty list, a network blip can erase the last useful result.

Fugleramme puts recent bird detections on an e-ink frame. BirdNET-Go owns audio classification; Fugleramme reads its HTTP API, chooses illustrations, and renders a page. I inspected version 0.22.1, pinned to commit d6baeef3bc7209e43c18476a17112aa494288808, and ran the application code with Python 3.12.6 on Windows. My detector responses and artwork were synthetic. I did not run a microphone, BirdNET weights, or an e-ink panel.

Three detector outcomes take different paths through the display application.

Two Responses at the HTTP Boundary

The package includes a fake BirdNET-Go server that speaks the same /api/v2 routes as the client expects. That lets a test cross a real loopback HTTP connection without loading a model. My downloadable probe starts one healthy server with no rows and another that answers with status 503.

Both clients ask for the all-time species summary. The result:

healthy_empty: []
http_503: Unavailable GET /analytics/species/summary answered 503

The distinction comes from ApiSource. It rejects a non-200 response before interpreting the body. Its cache holds successful values and Unavailable exceptions as different objects. A repeated failure remains a failure; the client does not replace it with an empty collection.

The normal cache lifetime is three seconds. Caching an exception for that interval avoids repeating connection work for several consumers of the same page. It also delays a fresh attempt, which is a tradeoff you should include in any recovery-time measurement. I disabled that cache in the render-loop probe to test the display policy on its own.

I also invoked the actual diagnostic CLI against the healthy, empty fake. These are selected lines from its output:

check_cli_exit: 0
ok   reachable              answered
ok   species, 6 hours       0 species
ok   species, 24 hours      0 species
ok   species, all time      0 species
ok   latest detection       none
ok   life list              none
ok   name languages         en, nb, sci

all good

Zero birds is a successful query. The diagnostic asks for data the frame uses, including language availability, instead of treating a responding health endpoint as proof that the whole integration works.

The First Run Stopped Before Rendering

From the pinned checkout, I installed the locked dependencies and ran the focused upstream tests:

uv sync --locked --python C:\Python312\python.exe
.venv\Scripts\python.exe -X utf8 -m pytest -q `
  tests/test_api.py tests/test_service.py tests/test_check.py
3 failed, 28 passed in 16.33s

All three failures entered service.run. They stopped at its diagnostic signal registration:

src\fugleramme\service.py:110
AttributeError: module 'faulthandler' has no attribute 'register'

The service calls the Unix stack-dump hook before initializing the panel or entering the loop. On this Windows interpreter, faulthandler.register does not exist; Windows also lacks SIGUSR1. The project's workstation development path therefore has a platform assumption before any display hardware gets involved.

I left the checkout unchanged. In the separate probe, I replaced that diagnostic hook with a no-op and supplied the missing signal constant. I also stubbed the panel, the kiosk server, and release checks. Those substitutions let the real detector client, render function, and service loop run in a bounded test. They do not establish that the unmodified Windows service starts.

One Render, Three Iterations

The probe supplies a blackbird detection and a small generated rectangle as its illustration. The first iteration queries the fake API and writes a 1600 × 1200 frame. At the first sleep boundary, the probe hashes the file and shuts down the detector server. It hashes the file again after each of the next two iterations, then stops the loop.

Run the downloaded script from the prepared checkout:

.venv\Scripts\python.exe -X utf8 path\to\outage-probe.py
{
  "render_calls": 1,
  "ticks": 3,
  "unique_frame_hashes": 1,
  "api_cache_ttl": 0,
  "panel": "stubbed"
}

I checked file identity, not whether the replacement illustration looked like a bird. The assertion proves that these failed polls left the rendered artifact intact.

In the service loop, the application keeps the previous render key when it catches Unavailable. It logs the transition into an outage and leaves the image alone. A successful empty response can reach the normal rendering path and produce an empty page when the mode's key changes. The exception path cannot make that decision.

The local simulation rendered once and retained the same file through two failed polls.

Input or eventObserved behaviorEvidence boundary
Healthy API with zero rowsEmpty result; diagnostic exit 0Local fake HTTP server
HTTP 503Unavailable exceptionActual client over loopback
Detector stops after renderingOne render across three iterations; one unique file hashReal loop with diagnostic and hardware substitutions
Windows service startupThree upstream tests fail before the loopUnmodified source

A Held Page Belongs to One Detector

Caching the last image creates another question: which detector produced it?

The kiosk handler tracks the source URL alongside its last successful PNG. During an outage at the same source, it can serve those bytes. After a source URL change, it clears the held page. Otherwise a user could point the frame at a different station and keep seeing the previous garden's birds.

I ran the two upstream HTTP tests for those cases:

.venv\Scripts\python.exe -X utf8 -m pytest -q tests/test_server.py `
  -k 'holds_its_last_page or pointing_at_another'
2 passed, 21 deselected in 9.04s

The outage test first confirms that another data route returns 503, then checks that the image route still returns the original bytes. The source-switch test expects 503 from the new, unreachable station instead of accepting the old image. This is a stronger check than unplugging a service while its TTL cache still has an answer.

Holding the page has a cost: a viewer can see stale detections. The service code retains the image through an outage without a maximum hold age. For a decorative bird frame, that is a defensible choice. A display that informs an operational decision would need a visible freshness policy as well.

The part I would reuse is the three-way result contract: detections, a successful empty result, or an unavailable source. The application can then choose a display policy for each state and test it without making claims about classifier accuracy.