Skip to content

Impedance imaging: publish a map only when it can be trusted

An impedance imaging system turns voltages measured around a chest, a pipe or a mixing tank into a picture of what is inside, and a clinician or plant engineer acts on that picture. The solver always returns a picture, even when half the electrodes have lost contact or when the measurements could be explained by several different interiors. This page shows a service that publishes the full quantitative map only when the measurements support it, falls back to a labelled qualitative image when the map cannot be unique, and publishes nothing when the electrodes are not reading properly. The runnable example id is sensor_fusion.

At a glance

Decision Publish a quantitative map, publish only a qualitative image, or publish nothing and re-acquire.
Owner The imaging service; the clinical or process engineer who reads the map.
Today's rule If the solver converged, publish the map.
What this adds A check that the electrodes agree with the reconstruction, and a policy that downgrades maps that cannot be unique.
Demo outcome 3 frames: 1 published map, 1 qualitative image (degrade), 1 held.
Example id sensor_fusion (uv run grounded-decision examples sensor_fusion)

The decision today

Electrical impedance tomography (EIT) places a ring of 16 electrodes around the body or vessel, injects small currents, measures the voltages at the boundary, and solves for the conductivity inside. Lung ventilation monitors and process tomography systems show the result as a map. Today the service publishes whatever the solver returns, and treats "the solver converged" as "this is the map".

Mistake What it costs
Publish a map the data did not determine A ventilation setting or valve change made on a picture that is partly invented.
Hold a frame that was fine One frame skipped; the operator re-acquires or waits for the next frame.

Where today's rule goes wrong

Two different things make a published map untrustworthy, and they need different responses.

  • The measurements are bad. An electrode with poor skin or wall contact gives voltages that no interior could produce. The solver still returns a map; it is a fit to bad contacts.
  • The question has no single answer. The maths that says "these voltages determine one interior" only holds under assumptions, for example that conductivity varies smoothly and is the same in every direction. Without them, different interiors can produce the same voltages. Clean data does not fix that.

In the demo, frame 1202 has clean data but was reconstructed without the smoothness assumption: the naive service publishes a quantitative map whose values the measurements did not pin down. Frame 1203 used the smooth model, but every other electrode reads far off its expected voltage: the naive service publishes a fit to a loose belt.

flowchart TD
    F[New frame and per-electrode residuals] --> M{Which model did the solver use?}
    M -- smooth, isotropic --> Q[Proposed: publish_map]
    M -- unconstrained --> D[Proposed: publish_qualitative_image]
    Q --> C{Do the electrodes support the reconstruction?}
    D --> C
    C -- yes, from publish_map --> A[Act: publish the quantitative map]
    C -- yes, from qualitative --> G[Degrade: publish qualitative image with a 'not unique' banner]
    C -- no --> H[Hold: publish nothing, check contact, re-acquire]

What the system checks, in your terms

The policy handles the uniqueness question: it maps the model class the solver used to what may be published. The evidence checks handle the measurement question, using the electrode ring: electrodes within three positions of each other are linked (they take part in the same drive and measure patterns), and each electrode is marked "fits" if its residual (the gap between measured and predicted voltages, in noise standard deviations) is at most 2.0.

Check Question in imaging terms Data it reads Fails when What a hold means
Neighbour agreement Do neighbouring electrodes tell a consistent story about whether the reconstruction fits? Fit/no-fit per electrode, ring links Neighbouring electrodes disagree in a pattern (e.g. every other one fails) The fit pattern looks like contact problems, not anatomy.
Knife-edge Is the link pattern so close to a cut-off that a small change would flip the reading? Number of electrodes and links Too few electrodes for the density to be read reliably Not expected here: with a fixed 16-electrode protocol the density is always 0.40 against a cut-off of 0.10, so this check always passes.
Unusual cases Is this frame's typical residual worse than on phantom runs we know were reconstructed correctly? Median electrode residual; 30 phantom-tank medians Median residual above almost every phantom run The data fits worse than any validated run.
Policy shape and progress Is the policy simple and able to reach a publish state? The policy itself, checked once Never in this demo — (these are build-time checks; the progress check is weak by construction, see progress).

What these checks do not verify

  • The library does not check uniqueness. The caller declares the model class through the event name (smooth_model or unconstrained_model). The module only guarantees that this declaration decides what may be published. If your solver labels a run "smooth" when it was not, the map is published.
  • Neighbour agreement does not care which way the electrodes agree. A frame where every electrode fails the fit still passes that check, because the electrodes agree with each other. In that frame the hold comes from the unusual-cases check (a median residual of 3.5 is worse than every phantom run). If the phantom calibration set is empty, that check passes by default and such a frame would publish. Keep the calibration set populated, or set Limits(require_conformal_calibration=True) so an empty set holds.
  • The knife-edge check is constant here. The density is fixed by the measurement protocol, so it cannot react to the data.

Data you provide

Input Source Refresh Minimum / quality bar
Per-electrode residuals for this frame Your inversion's forward model (it already computes them) Every frame One value per electrode, in ring order, in noise standard deviations.
Model class used Your solver configuration Every frame Must reflect the prior actually used, not the default.
Phantom median residuals (calibration set) Phantom-tank runs with known interior, judged correctly reconstructed After each phantom calibration and after any hardware change At least 9 runs at α = 0.10 (fewer and the check can never hold); the demo uses 30. Never include live frames.

Worked example

Frame Event Electrodes What the checks found Verdict What the operator sees
1201 smooth_model All 16 fit, median residual 0.95 Electrodes agree; residual typical of phantom runs Act publish_map The quantitative map.
1202 unconstrained_model Same clean data All checks pass; the policy marks this state as a reduced action Degrade publish_qualitative_image A qualitative image with a "not unique" banner; no numbers.
1203 smooth_model Every other electrode off (8 of 16 fit), median 2.29 Neighbours disagree in an alternating pattern; residual worse than every phantom run Hold (record verdict abstain), proposed publish_map Nothing published; "check contact and re-acquire".
The numbers (from uv run grounded-decision examples sensor_fusion)

All three frames: \(n = 16\) electrodes, 48 links, average links per electrode \(d = 6\), link density \(\hat p = 0.40\), cut-off \(p^{*} = 0.10\).

Frame Neighbour agreement \(\lambda\) Evidence strength \(d\lambda^2\) (needs > 1.05) Median residual Unusualness \(p\) (needs > 0.10)
1201 1.0 6.0 pass 0.95 0.710 pass
1202 1.0 6.0 pass 0.95 0.710 pass
1203 −0.333 0.667 fail 2.29 0.032 fail

\(\lambda\) is how much more often linked electrodes share a fit label than chance (1 = always, 0 = chance, negative = they tend to differ; the strength uses \(\lambda^2\), so a strong alternating pattern would still count as signal — here it is too weak). At \(n = 16\) the knife-edge half-width is 0.209 and \(|0.40 - 0.10| = 0.30\) lies outside it. The unusualness \(p\) for 1201 is \((1 + 21)/(1 + 30)\): 21 of 30 phantom medians are at least 0.95. For 1203, 2.29 is above all 30, so \(p = 1/31\).

The --json summary reports naive_acts: 3, grounded_acts: 1, grounded_abstains: 1, blocked_overconfident_acts: 2. That last count includes the degrade row: it counts every frame that did not publish the full map, not only holds.

What happens next

  • Act. Publish the quantitative map and store the record with the frame.
  • Degrade. Carry out record.action: publish the qualitative image with a "not unique" banner. Never export quantitative values from it (no region-of-interest numbers, no trend lines).
  • Hold. Publish nothing for this frame. Show the operator the reason: a failed agreement check plus a high residual means "check electrode contact and re-acquire", not "try another model". Frames are independent, so the next good frame publishes normally. If holds continue for longer than your clinical or process protocol allows without a picture (set this with the device owner), alarm the operator rather than silently showing the last good map.
  • Feedback. Phantom runs, not live frames, feed the calibration set.

Tuning and expected workload

  • RESIDUAL_TOLERANCE_IN_NOISE_UNITS = 2.0 decides what "fits" means per electrode. Set it from the residual spread on good phantom runs.
  • SHARED_PATTERN_REACH = 3 follows your drive/measure pattern; change it only if the protocol changes.
  • The unusualness cut-off α = 0.10 means about one in ten genuinely good frames whose residual is in the worst tenth of phantom runs will be held. With good contact that rate should be low; a rising hold rate is a belt or gel problem worth tracking per device. See choosing parameters.

Limits, risks and compliance

  • Not a medical device. Using this in a ventilation or patient monitoring path is a regulated-device decision; this example is decision support for publishing images, with synthetic data.
  • Uniqueness is declared, not proven. See the warning above.
  • Synthetic data. The residuals and phantom runs are invented to show the mechanism.

Wiring it in

  1. At service start, build the module once: imaging_module = build().
  2. After each phantom calibration (and after every hardware change), refresh PHANTOM_RUN_MEDIAN_RESIDUALS.
  3. Per frame, take the per-electrode residuals your solver already produces and choose the event from the model class the reconstruction actually used:
from grounded_decision.examples.sensor_fusion import build, electrode_fit_evidence

evidence = electrode_fit_evidence(residual_by_electrode)
event_name = "smooth_model" if used_smooth_isotropic_prior else "unconstrained_model"
record = imaging_module.decide(Event(event_name, {"frame": frame_id}), evidence)
imaging_module.reset()  # frames are independent
append_to_audit_log(record.to_dict())
if record.verdict == "act":
    publish_quantitative_map(frame_id)
elif record.verdict == "degrade":
    publish_qualitative_image(frame_id, banner="not unique")
else:
    ask_operator_to_check_contact(frame_id, record.reason)

append_to_audit_log, publish_quantitative_map, publish_qualitative_image and ask_operator_to_check_contact are your own functions.

  1. Concurrency: the module is not thread-safe. Use one module per worker, or reset it per request under a lock.

The policy itself is a three-state chart: intake goes to quantitative_map (act, publish_map) on smooth_model, and to qualitative_only (degrade, publish_qualitative_image) on unconstrained_model. See build() in src/grounded_decision/examples/sensor_fusion.py.

Run it yourself

uv run grounded-decision examples sensor_fusion

The output prints one block per frame. How its labels map to the words on this page:

CLI label Meaning here
fire, ACT act: the proposed action is carried out
DEGRADE, "fire reduced action" degrade: the policy's reduced action is carried out
HOLD, hold_for_human, ABSTAIN hold: nothing is carried out; record verdict abstain
strength dλ², "KS" evidence strength and its 1.05 line
"flicker band", p* the knife-edge window and its cut-off
bag the calibration set
cursor, extras['state'] the saved state (not used here: frames are independent)

The naive-versus-grounded counts are in the summary field of --json output. On the degrade row the CLI also prints proposed_action=… (blocked, kept on the record) and "ledger committed hold_for_human"; read the OUTPUT line and the record's verdict (degrade, action carried out) instead.

Further reading