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 |
|---|---|---|
![]() |
![]() |
![]() |
A contrast bolus streaming from the catheter tip, in all three views. Regenerate
with python tools/capture_media.py.
- 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
EIvalues. 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.simimports 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.
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\pipThen 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.jsonStart 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.
| 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.
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.jsonPush 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 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.
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() 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.
- 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.
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.
pytest # full suite
pytest -m "not benchmark" # what CI runs; the frame-rate gate is wall-clock
ruff check src tests
black src tests
mypyPhysical 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%.
- Documentation — docs/DEVICES.md for the device catalogue and what each anatomy tolerates; docs/REBUILD_PLAN.md for the phase-by-phase plan, every measurement, and the literature register.
- Questions and bugs — open an issue at github.com/JFrusher/CathSim/issues. For a physics bug, include the scenario file and the device.
Maintained by Jacob Frusher.
Pull requests are welcome. Two house rules matter more than style:
- Every physical number needs a source. Cite it in the docstring or the
device's
sourcefield. If it is an estimate, mark itESTIMATE— 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.


