Skip to content

Repository files navigation

CathSim 2D

CI Python 3.10+ Code style: black

A physics-driven 2D endovascular catheter and guidewire trainer for radiology residents. Push, torque and deflect a real guidewire through real anatomy, under a fluoroscope that costs you dose, and get graded on force, trauma, contrast and time against published clinical bands.

Every force in the model comes from a documented closed-form relation, and every number that could have been tuned by taste is traceable to a citation instead — NIST attenuation tables, ICRP dose reference levels, Rafii-Tari's force-sensing skill bands. Where a value is an estimate, it says so in capitals and a test asserts it keeps saying so.

Anatomical Fluoroscopy DSA
Anatomical view Fluoroscopic view DSA view

A contrast bolus streaming from the catheter tip, in all three views. Regenerate with python tools/capture_media.py.

Why it exists

  • The device behaves like a device. An implicit discrete elastic rod carries real flexural rigidity — a floppy tip transitioning to a stiff shaft, per device, from published EI values. A Bentson tracks where a Lunderquist straightens the anatomy out.
  • The image is earned, not given. Fluoroscopy runs off a pedal at 7.5 pulses/s with last-image hold, and every screening second bills to a dose ledger. Working off the held image is the habit being trained.
  • The X-ray is physics, not a filter. Beer-Lambert transmission with Poisson photon statistics, so noise rises where the beam is most attenuated and DSA subtracts exactly. A 0.014" wire renders faint grey; its platinum marker band renders black.
  • Grading you can defend. Peak force, force-impact-over-time, contrast volume against eGFR, and dose against the diagnostic reference level — each band cited, so a trainee's report says why a mark was lost.
  • Headless and deterministic. cathsim.sim imports no pygame and replays bit-exactly from a few kilobytes of actions, so batch sweeps, after-action review and RL agents all drive the same core.
  • Anatomy and devices are data. New scenarios are JSON; new devices are JSON; custom anatomy imports from SVG. No code change.

Quickstart

Requires Python 3.10+ (CI tests 3.11–3.13 on Linux, Windows and macOS).

git clone https://github.com/JFrusher/CathSim.git
cd CathSim
python -m venv .venv
.venv/bin/pip install -e ".[dev]"          # Windows: .venv\Scripts\pip

Then run a scenario:

.venv/bin/python -m cathsim.ui.demo scenarios/sandbox_freeplay.json
.venv/bin/python -m cathsim.ui.demo scenarios/driving_test_04.json

Start with sandbox_freeplay. An open channel, no target, no limits — nothing terminates and nothing is graded, so it is somewhere to find out what a wire does under torque and deflection before any of it counts. Then driving_test_04: a run-in, an 80 mm bend, a bifurcation whose easy branch is the wrong one, a focal stenosis, and a blind side-branch short of the target.

Record a run for replay with --record run.json.

Controls

Key Action
W / S Advance / withdraw the shaft
A / D Torque the shaft counter-clockwise / clockwise
Q / E Deflect the tip
SPACE Inject contrast at the tip
F Fluoroscopy pedal — toggles the live image (starts on)
V Cycle anatomical / fluoroscopy / DSA
ESC Quit and print the performance report

The pedal is a toggle rather than a held key because a real operator pedals with a foot and here the same hand is already on six other keys. Pulses arrive at 7.5/s, not 30, so the device genuinely moves between pictures — raise KeyboardMouseController(fluoro_rate_pps=15) for a smoother image at twice the dose.

Driving it from a physical rig

A proxy rod on two encoders, a servo friction brake, a footswitch and a deflection pot — about £170 of parts, built from hardware/:

pip install -e ".[hardware]"
python hardware/tools/monitor.py COM3                       # frames decode?
python hardware/tools/calibrate.py COM3 -o hardware/rig_local.json
python -m cathsim.ui.demo scenarios/sandbox_freeplay.json --serial COM3 --rig hardware/rig_local.json

Push and torque the rod, and the brake bites in proportion to the wall force the simulation is computing. The footswitch restores the held pedal the keyboard could only fake as a toggle, which is the habit the dose ledger exists to teach. The simulated HandModel switches itself off on this path — a real hand already tremors and regrips, and stacking the model on top would double both.

Quitting prints a report:

=======================================================
CATHSIM PERFORMANCE REPORT
=======================================================
Scenario ID:            ICA_SIPHON_05
Completion Status:      SUCCESS
Elapsed Time:           02:14.5 min
Max Wall Force:         0.42 N (PASS <= 0.80 N)
Accumulated Trauma Index: 14.2 Ns
Contrast Volume Used:   18.5 mL
Overall Grade:          A- (PROFICIENT)
=======================================================

The grade is the mean headroom across peak force, accumulated trauma and elapsed time against the scenario limits. Any safety breach grades F.

Scenarios and devices

Scenarios live in scenarios/ as plain JSON:

{
  "id": "ICA_SIPHON_05",
  "vessel": { "generator": "ica_siphon" },
  "catheter": { "device": "microwire_014_soft", "tip_shape": "straight" },
  "flow": { "v_max": 0.20 },
  "target": { "position": [0.0799, 0.0199], "tolerance": 0.003 },
  "limits": { "max_wall_force": 0.8, "max_trauma": 12.0, "time_limit_s": 300.0 },
  "dose_budget": { "drl_dap": 120.0 }
}

vessel picks a built-in generator or imports custom anatomy with {"svg": "path/to/walls.svg", "scale": 0.001}. catheter names any device in devices/ and may name an outer_device to track coaxially.

Shipped scenarios: sandbox_freeplay, driving_test_04, three peripheral cases (femoral_access_01, carotid_stenosis_02, aaa_navigation_03), and four neurovascular cases authored from published anatomical dimensions — not reverse-engineered from a target score:

scenario anatomy device peak load
ica_siphon_05 cavernous genu, R = 6–9 mm soft 0.014" microwire 0.66 N
m1_stenosis_06 70% M1 narrowing, 0.42 mm lumen soft 0.014" microwire 2.34 N
type_iii_arch_07 acute LCCA take-off, R = 22 mm 0.035" hydrophilic + 5F 1.43 N
aneurysm_neck_08 7 mm sac on a 4 mm neck, out of plane soft 0.014" microwire 1.69 N

All four sit inside or beside Rafii-Tari's 0.11–1.75 N band. Eleven devices ship, from a Bentson at 40 N·mm² to a Lunderquist at 4857 — see docs/DEVICES.md for the catalogue, the sources, and which device each anatomy tolerates.

Using it as a library

The simulation core is headless, deterministic, and pygame-free:

from cathsim.scenario import Scenario
from cathsim.sim import Action, Simulation

sim = Simulation(Scenario.load("scenarios/ica_siphon_05.json"))
for _ in range(300):                       # 10 s at 30 FPS
    obs = sim.step(Action(advance=0.01))   # 10 mm/s insertion
print(obs.peak_force, obs.fit, obs.status)

A run is fully described by its scenario and its action sequence, so recordings are kilobytes and replay bit-exactly:

from cathsim.recording import Recording

sim, trace = Recording.load("run.json").replay()

How it works

   keyboard ──→ control.py ──┐
   serial rig ─→ hardware/  ─→ Action ──→ sim.py (headless core)
                                              │
        ┌─────────────────────────────────────┼──────────────────────┐
        ↓                    ↓                ↓                      ↓
  physics/rod.py      physics/contact.py  physics/dye.py       scenario.py
  implicit discrete   analytic vessel     semi-Lagrangian      objectives,
  elastic rod         SDF + stick-slip    advection-diffusion  grading, report
        │                    │                │                      │
        └──── physics/runner.py (frame driver) ┘                     │
                             │                                       │
              physics/hemodynamics.py (Womersley flow)               │
                             │                                       │
                             ↓                                       ↓
                     imaging/ (C-arm, Beer-Lambert X-ray) ──→ ui/ (renderer, HUD)

Flexural dynamics. A 2D discrete elastic rod with node positions as degrees of freedom, lumped mass, and bending forces from discrete turning angles — exact and rotation-invariant, so a J-tip in a tortuous vessel is modelled properly and not only in the small-deflection limit. Bending is solved linearly-implicitly using the exact Gauss-Newton Hessian, which removes the omega * dt timestep bound: explicitly integrated, literature guidewire stiffnesses need 1,246–3,269 substeps per frame at 30 FPS; implicitly, 16 substeps hold a tortuous carotid. That is what lets a device be as stiff as the device it models.

Contact. A vessel branch is a centreline plus a per-vertex lumen radius, which is a signed distance field: distance_to_centreline - local_radius. Exact normals, exact penetration depth, no collision engine and no tessellation — and the drawn lumen and the collided lumen are the same object, where an offset wall polyline disagreed with the true tube by 37% at a tight stenosis. Friction is stick-slip. Wall polyline generation is render-only.

Blood flow. The exact Womersley solution, evaluated per harmonic from the pressure waveform's Fourier series and cached as a lookup table. At real Womersley numbers (15–20 aortic, 6–10 carotid) the profile is blunt, not parabolic. Radius is per-vertex, so no-slip holds at the local wall and a stenosis accelerates flow as v ~ 1/r² — which is what makes a stenosis findable on the image and gradeable against duplex criteria.

Contrast transport. dC/dt = D∇²C - v·∇C, masked to the lumen. Advection is semi-Lagrangian with an RK2 backtrace: unconditionally stable, so the step is set by trajectory accuracy rather than a CFL bound — 5 passes per frame where first-order upwind needed 361, and 3.6× less diffusive.

Synthetic fluoroscopy. A transmission integral, not a drawing style:

tau = Σ mu_k · L_k       N = Poisson(N0 · exp(-tau))       I = log(N0 / N)

mu comes from the NIST XCOM / ICRU-44 tables at an effective energy of 0.5 · kVp. Three things follow without being asked for. Optical depth is a sum, so the image needs no depth sorting however many structures overlap. Noise is Poisson, so it rises where the beam is most attenuated — the one property a uniform grain texture cannot fake. And subtraction is exact: both frames are optical depths, so DSA = tau_live - tau_mask cancels bone and device algebraically.

The vessel is not a rigid pipe. Lumen radius is decided in one place, so the collided lumen, the perforation check and the drawn vessel cannot disagree. It distends ~6% over the cardiac cycle and constricts where it was scraped — sustained contact drives a local narrowing that relaxes over a ~45 s time constant, far slower than the ~8 s it takes to provoke.

Depth: vessel centrelines carry a z, so imaging/carm.py projects a real cone beam and branches that overlap in one projection separate in another. The device's depth is derived from the vessel it lies in, never solved.

Known limitations of a planar model

  • Angulation changes the picture, not the patient. Swinging the C-arm moves nothing inside the anatomy — true of a real image, false of real out-of-plane mechanics. A wire that looks like it is prolapsing in one view may be doing something else, and this model cannot tell you which.
  • No out-of-plane motion. Torque rotates the shaft within the plane, so it cannot steer a tip around a bend the way axial rotation does in 3D.
  • No buckling. The rod drops the geometric-stiffness term, so where a real device would prolapse back out of the vessel — the thing an operator feels and corrects — this one has nowhere to put the axial load and coils instead. That sets a push-rate ceiling; tortuous vessels want slow pushes here, which is at least the same direction as the clinical advice.
  • Pre-shaped tips need room. A J-tip's curve is ~9.5 mm across, so in an 8 mm femoral lumen it wedges where a real J-wire would rotate out of plane. Scenarios therefore pick the tip to suit the anatomy — which is the clinical decision anyway.

Fuller discussion, with the measurements behind docs/REBUILD_PLAN.md.

Project layout

src/cathsim/
├── core/        constants (SI), polyline/beam math, 2D vector type
├── physics/     implicit rod, contact SDF, Womersley flow, contrast transport,
│                fixed-substep frame driver
├── imaging/     C-arm geometry, attenuation tables, the X-ray chain, pulse
│                timing. Pure numpy — no pygame, so it is testable headless
├── vessel/      vessel geometry (with depth), anatomy generators, SVG import
├── ui/          renderer (anatomical / fluoro / DSA), HUD, interactive demo
├── hardware/    input rig: wire protocol, per-machine calibration, serial driver
├── sim.py       headless simulation core
├── device.py    device catalogue loader
├── control.py   keyboard driver, hand model, haptics
├── assessment.py contrast, dose and skill ledgers
├── scenario.py  scenario config, objectives, grading, report
└── recording.py run recording and bit-exact replay
devices/         device definitions (JSON)
scenarios/       scenario definitions (JSON)
docs/            device catalogue, handoff notes, rebuild plan
hardware/        input rig: firmware (RP2040), CAD, BOM, bench tools
tools/           media capture
tests/           unit, integration, stability and benchmark tests

hardware/ is not a Python package and ships in no wheel — it holds the firmware, the printable parts and the bench procedures. The Python that talks to the rig lives in src/cathsim/hardware/, under the same type, lint and coverage gates as the rest of the simulator.

Development

pytest                     # full suite
pytest -m "not benchmark"  # what CI runs; the frame-rate gate is wall-clock
ruff check src tests
black src tests
mypy

Physical parameters are SI throughout and live in src/cathsim/core/constants.py; per-device parameters live in devices/. After changing either, run pytest — the cantilever gate catches a mis-scaled beam and the stability tests catch a configuration that will diverge.

CI runs lint, format, types and tests on Linux, Windows and macOS across Python 3.11–3.13. Coverage is gated at 85%.

Getting help

Contributing

Maintained by Jacob Frusher.

Pull requests are welcome. Two house rules matter more than style:

  1. Every physical number needs a source. Cite it in the docstring or the device's source field. If it is an estimate, mark it ESTIMATE — a test asserts estimates keep announcing themselves.

Before opening a PR, run ruff check src tests, black src tests, mypy and pytest — the same gates CI applies.

About

2D endovascular simulation: implicit elastic rod solver, analytic wall contact, synthetic fluoroscopy/DSA from a Beer-Lambert transmission integral. Literature-traceable physics, verified against closed-form solutions. Research prototype, educational only.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages