Skip to content

Commit f9358ce

Browse files
committed
Refactor tests and add neurovascular scenarios
- Enhanced `test_trauma_index_lands_in_the_literature_band` to assert against run perforation. - Introduced `test_neurovascular.py` with comprehensive tests for neurovascular devices and scenarios, ensuring anatomical correctness and device performance. - Updated `test_runner.py` to validate device stiffness in the carotid siphon anatomy. - Modified `test_session.py` to ensure injection rates are accurately reflected in the reservoir. - Added `test_xray.py` to validate synthetic fluoroscopy against photon physics, ensuring imaging chain integrity. - Corrected `capture_media.py` to use `advance_rate` instead of `advance_force` for command input.
1 parent be5dc26 commit f9358ce

48 files changed

Lines changed: 4958 additions & 197 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,22 @@ in it.
4949
| `A` / `D` | Torque the shaft counter-clockwise / clockwise |
5050
| `Q` / `E` | Deflect the tip |
5151
| `SPACE` | Inject contrast at the tip |
52+
| `F` | **Fluoroscopy pedal** — toggles the live image (starts on) |
5253
| `V` | Cycle anatomical / fluoroscopy / DSA |
5354
| `ESC` | Quit and print the performance report |
5455

56+
**The image is not free.** Screening time accrues to the dose ledger for every
57+
frame fluoroscopy is on, and the HUD shows it. Switch it off with `F` and the
58+
screen keeps the last frame acquired — last-image hold, as a real one does — and
59+
the meter stops. Working off the held image rather than screening continuously is
60+
the habit being trained.
61+
62+
It is a toggle rather than a held key because a real operator pedals with a foot
63+
and here the same hand is already on six other keys. Pulses arrive at 7.5/s, not
64+
30, so the device genuinely moves between pictures; raise
65+
`KeyboardMouseController(fluoro_rate_pps=15)` if you want a smoother image and
66+
are willing to pay twice the dose for it.
67+
5568
Development commands: `pytest`, `ruff check src tests`, `black src tests`,
5669
`mypy`, and `pytest -m benchmark` for the frame-rate gate.
5770

@@ -133,9 +146,43 @@ Post-solve impulses convert to force as `F = J / dt` and integrate into the
133146
Wall Trauma Index `WTI = integral(sum |F_n|) dt`, alongside per-wall-segment
134147
peak forces and a perforation warning above 0.8 N.
135148

136-
**Contrast transport.** `dC/dt = D * lap(C) - v . grad(C)`, solved with
137-
first-order upwind advection and an explicit diffusion stencil, sub-stepped to
138-
the CFL limit and masked to the vessel lumen.
149+
**Contrast transport.** `dC/dt = D * lap(C) - v . grad(C)`, masked to the vessel
150+
lumen. Advection is **semi-Lagrangian with an RK2 backtrace**, which is
151+
unconditionally stable, so the step is set by trajectory accuracy rather than by
152+
a CFL bound — 5 passes a frame where first-order upwind needed 361, because its
153+
bound was set by the stenosis jet at 3.6 m/s. It is also 3.6x less diffusive:
154+
measured effective diffusivity is 1.12x the configured coefficient against
155+
upwind's 4.07x, so `diffusivity` finally means what it says. Diffusion stays
156+
explicit and is sub-stepped on its own, genuinely stability-bound, limit.
157+
158+
**Synthetic fluoroscopy.** The X-ray views are a Beer-Lambert transmission
159+
integral, not a drawing style:
160+
161+
```
162+
tau = sum_k mu_k * L_k N = Poisson(N0 * exp(-tau)) I = log(N0 / N)
163+
```
164+
165+
`mu` comes from the NIST XCOM / ICRU-44 tables at an effective energy of
166+
`0.5 * kVp`; `L` is an analytic chord through a vessel, a device or the phantom.
167+
Three things follow without being asked for. Optical depth is a **sum**, so the
168+
image needs no depth sorting however many structures overlap. Noise is Poisson,
169+
so it rises where the beam is most attenuated — the one property a uniform grain
170+
texture cannot fake. And a device contributes `mu * pi a^2 * L / A_pixel`, its
171+
real volume in the ray, so a 0.014" wire renders faint grey while the platinum
172+
marker band on the same wire renders black.
173+
174+
Subtraction is therefore **exact**: both frames are optical depths, so
175+
`DSA = tau_live - tau_mask` cancels bone and device algebraically, leaving the
176+
contrast column alone.
177+
178+
**The vessel is not a rigid pipe.** Lumen radius is decided in one place, so
179+
compliance and vasospasm enter there and the collided lumen, the perforation
180+
check and the drawn vessel cannot disagree about how wide the artery is. It
181+
distends ~8% over the cardiac cycle, on the same beat the flow field and the
182+
contrast solver are using. And it constricts *where it was scraped*: sustained
183+
contact drives a local narrowing that reaches 12% in ten seconds and relaxes
184+
over about a minute if left alone, so a trainee who works one spot can find it
185+
closing down on them.
139186

140187
## Numerical stability
141188

@@ -172,6 +219,24 @@ Consequences worth knowing before changing parameters:
172219
carotid cases use a straight tip and the aneurysm case a J-tip.
173220
- **No out-of-plane motion.** Torque rotates the shaft within the plane, so it
174221
cannot steer a tip around a bend the way axial rotation does in 3D.
222+
- **No buckling, and it sets a push-rate ceiling.** The rod deliberately drops
223+
the geometric-stiffness term, so it has no compression instability: where a
224+
real device would prolapse — back out of the vessel, which is the thing an
225+
operator feels and corrects — this one has nowhere to put the axial load and
226+
coils instead. Measured on the siphon, a soft microwire survives at 2, 4 and
227+
6 mm/s (0.47–0.64 N) and coils outright at 8 (98 N, 35 of 59 joints past 60°).
228+
A real wire has no hard failure between 7 and 8 mm/s, so treat that cliff as
229+
the model's edge rather than the anatomy's. Tortuous vessels want slow pushes
230+
here, which is at least the same direction as the clinical advice. It is also
231+
why a coaxial assembly can be driven through the arch but not the siphon.
232+
- **Angulation changes the picture, not the patient.** Vessel centrelines carry
233+
a `z`, so the C-arm projects a real cone beam: branches that overlap in one
234+
projection separate in another, and depth reads through differential
235+
magnification. But the solver stays planar, so swinging the gantry moves
236+
nothing inside the anatomy. That is true of a real image and false of real
237+
out-of-plane mechanics — a wire that looks like it is prolapsing into a branch
238+
in one view may be doing something else, and this model cannot tell you which.
239+
The device's depth is *derived* from the vessel it lies in, never solved.
175240
- **Contact is impulse-based**, using pymunk's solver with friction and
176241
restitution as the wall material knobs, rather than the explicit penalty
177242
springs the model's contact equation describes.
@@ -210,9 +275,34 @@ Scenario files in `scenarios/` are plain JSON:
210275
}
211276
```
212277

213-
`vessel` selects a built-in generator (`femoral_bifurcation`,
214-
`tortuous_carotid`, `aaa`) or imports custom anatomy with
215-
`{"svg": "path/to/walls.svg", "scale": 0.001}`. Quitting a run prints:
278+
`vessel` selects a built-in generator or imports custom anatomy with
279+
`{"svg": "path/to/walls.svg", "scale": 0.001}`. Optional blocks: `dose_budget`
280+
sets the diagnostic reference level a run is billed against, and `catheter` can
281+
name an `outer_device` to track coaxially over the inner one.
282+
283+
### Neurovascular
284+
285+
Four cases authored from published anatomical dimensions — **not** reverse
286+
engineered from a target score. The device was then chosen to fit, which is the
287+
order the clinical decision happens in.
288+
289+
| scenario | anatomy | device | peak load |
290+
|---|---|---|---|
291+
| `ica_siphon_05` | cavernous genu, R = 6–9 mm | soft 0.014" microwire | 0.66 N |
292+
| `m1_stenosis_06` | 70% M1 narrowing, 0.42 mm lumen | soft 0.014" microwire | 2.34 N |
293+
| `type_iii_arch_07` | acute LCCA take-off, R = 22 mm | 0.035" hydrophilic **+ 5F** | 1.43 N |
294+
| `aneurysm_neck_08` | 7 mm sac on a 4 mm neck, out of plane | soft 0.014" microwire | 1.69 N |
295+
296+
All four sit inside or beside Rafii-Tari's 0.11–1.75 N band, and `type_iii_arch`
297+
is the anatomy that band was *measured* on, run with the pairing actually used.
298+
299+
`aneurysm_neck` is the one that needs the C-arm: the sac lies out of the working
300+
plane, so at AP it is superimposed on its parent vessel and the neck cannot be
301+
seen. Angulating opens it from 6.6 mm of projected separation to 17.5 mm — and
302+
some angulations make it *worse*, which is what makes finding a working
303+
projection a skill rather than a formality.
304+
305+
Quitting a run prints:
216306

217307
```
218308
=======================================================
@@ -236,11 +326,13 @@ elapsed time against the scenario limits; any safety breach grades F.
236326
```
237327
src/cathsim/
238328
├── core/ constants (SI), beam and polyline math, 2D vector type
239-
├── physics/ catheter assembly, fluid field and drag, contrast transport,
240-
│ trauma analytics, fixed-substep engine
241-
├── vessel/ vessel geometry, clinical scenarios, SVG import
329+
├── physics/ rod solver, contact SDF, hemodynamics, contrast transport,
330+
│ fixed-substep frame driver
331+
├── imaging/ C-arm geometry, attenuation tables, the X-ray chain, pulse
332+
│ timing. Pure numpy -- no pygame, so it is testable headless
333+
├── vessel/ vessel geometry (with depth), clinical scenarios, SVG import
242334
├── ui/ renderer (anatomical / fluoro / DSA), HUD, interactive demo
243-
├── control.py keyboard and serial-hardware drivers, haptics
335+
├── control.py keyboard and serial drivers, the hand model, haptics
244336
└── scenario.py scenario config, assessment, performance report
245337
scenarios/ example scenario configurations
246338
tests/ unit, integration, and stability tests

devices/guide_catheter_6f.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"id": "guide_catheter_6f",
3+
"name": "Guide catheter 6F",
4+
"class": "catheter",
5+
"outer_diameter_m": 0.002,
6+
"density_kg_m3": 1400,
7+
"flexural_profile": [
8+
{ "from_tip_m": 0.00, "to_tip_m": 0.02, "EI_n_mm2": 180.0, "note": "soft atraumatic tip" },
9+
{ "from_tip_m": 0.02, "to_tip_m": 0.08, "EI_n_mm2": 520.0, "note": "transition" },
10+
{ "from_tip_m": 0.08, "to_tip_m": 2.00, "EI_n_mm2": 900.0, "note": "braided support shaft" }
11+
],
12+
"tip_shape": { "kind": "angled" },
13+
"coating": { "kind": "ptfe", "mu_kinetic": 0.08, "static_ratio": 2.0 },
14+
"confidence": "estimate",
15+
"source": "SHAPED ESTIMATE, not a quoted measurement. Positioned by role rather than by a table: a guide catheter exists to hold a position in the arch and take the reaction while smaller devices work through it, so it must be stiffer than the 5F diagnostic catheter already in this catalogue (675 N.mm2) and stiffer again than any 0.035\" wire except the Lunderquist (4858 N.mm2). 900 N.mm2 puts it between those. The 20 mm soft tip is what stops that shaft being a cutting instrument at the ostium, and it is the reason a guide is described by its tip shape rather than its shaft. Replace with measured values when a guide flexural table is to hand."
16+
}

devices/microcatheter_027.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"id": "microcatheter_027",
3+
"name": "Microcatheter 2.7F (0.027\" ID)",
4+
"class": "catheter",
5+
"outer_diameter_m": 0.0009,
6+
"density_kg_m3": 1400,
7+
"flexural_profile": [
8+
{ "from_tip_m": 0.00, "to_tip_m": 0.03, "EI_n_mm2": 6.0, "note": "floppy distal segment" },
9+
{ "from_tip_m": 0.03, "to_tip_m": 0.10, "EI_n_mm2": 22.0, "note": "braided transition" },
10+
{ "from_tip_m": 0.10, "to_tip_m": 2.00, "EI_n_mm2": 60.0, "note": "braid-reinforced shaft" }
11+
],
12+
"tip_shape": { "kind": "straight" },
13+
"coating": { "kind": "hydrophilic", "mu_kinetic": 0.037, "static_ratio": 1.5 },
14+
"confidence": "estimate",
15+
"source": "SHAPED ESTIMATE, not a quoted measurement. Stated as EI directly rather than as a modulus, because a microcatheter is a braid-reinforced composite tube and a solid-section modulus would be meaningless for it -- which is exactly why the schema accepts EI_n_mm2. Scale is set by two anchors: it must be stiffer than the 0.014\" wire it is tracked over (2-47 N.mm2) or it would not follow, and far softer than a 5F diagnostic catheter (675 N.mm2 in this catalogue) or it would not reach intracranial vessels at all. The distal 30 mm at 6 N.mm2 is the segment that has to turn into a perforator. Replace with measured values when a microcatheter flexural table is to hand."
16+
}

devices/microwire_014_soft.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"id": "microwire_014_soft",
3+
"name": "Soft-tip microwire 0.014\"",
4+
"class": "guidewire",
5+
"outer_diameter_m": 0.000356,
6+
"density_kg_m3": 6450,
7+
"flexural_profile": [
8+
{ "from_tip_m": 0.00, "to_tip_m": 0.03, "E_pa": 2.5e9, "note": "shapeable floppy tip, platinum-coiled" },
9+
{ "from_tip_m": 0.03, "to_tip_m": 0.06, "E_pa": 2.0e10, "note": "distal taper" },
10+
{ "from_tip_m": 0.06, "to_tip_m": 2.00, "E_pa": 6.0e10, "note": "nitinol shaft" }
11+
],
12+
"tip_shape": { "kind": "straight" },
13+
"coating": { "kind": "hydrophilic", "mu_kinetic": 0.037, "static_ratio": 1.5 },
14+
"confidence": "estimate",
15+
"source": "SHAPED ESTIMATE, not a quoted measurement. The Harrison GJ et al. table this catalogue is anchored on (J Endovasc Ther 2011;18(6):797-801, PMID 22149229) is a 0.035\" peripheral family and does not cover 0.014\" neuro wires, so there is no family anchor to position against and these are shaped from geometry and material instead. The section is what fixes the scale: a 0.014\" round section has I = 7.88e-4 mm^4, so a wire of solid austenitic nitinol (E = 75 GPa) would be 59 N.mm2 and that is the hard ceiling for the class. The shaft here sits just under it at 47 N.mm2; the 30 mm floppy tip at 2 N.mm2 is roughly 25x softer, which is the order of magnitude that lets a tip be shaped by hand and led into a perforator without tearing it. Replace with measured values when a microwire flexural table is to hand. Note this is ~6x softer than the softest 0.035\" in the catalogue (Bentson, 40 N.mm2 shaft) purely because the section is 6x smaller in diameter -- the moduli are comparable."
16+
}

devices/microwire_014_support.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"id": "microwire_014_support",
3+
"name": "Support microwire 0.014\"",
4+
"class": "guidewire",
5+
"outer_diameter_m": 0.000356,
6+
"density_kg_m3": 6450,
7+
"flexural_profile": [
8+
{ "from_tip_m": 0.00, "to_tip_m": 0.015, "E_pa": 8.0e9, "note": "short soft tip" },
9+
{ "from_tip_m": 0.015, "to_tip_m": 0.04, "E_pa": 4.0e10, "note": "short taper" },
10+
{ "from_tip_m": 0.04, "to_tip_m": 2.00, "E_pa": 7.2e10, "note": "near-solid nitinol shaft" }
11+
],
12+
"tip_shape": { "kind": "straight" },
13+
"coating": { "kind": "ptfe", "mu_kinetic": 0.08, "static_ratio": 2.0 },
14+
"confidence": "estimate",
15+
"source": "SHAPED ESTIMATE, not a quoted measurement. Same sectional reasoning as microwire_014_soft: I = 7.88e-4 mm^4 at 0.014\", so solid austenitic nitinol at 75 GPa gives 59 N.mm2 as the class ceiling. This wire is deliberately near it (57 N.mm2 shaft, 6 N.mm2 over a short 15 mm tip) because its job is support for tracking a microcatheter, not exploration -- the clinical pairing is to cross with a soft wire and exchange up to this one. It should feel like it wants to straighten the anatomy, and because wall load is linear in EI it presses roughly 25x harder than the soft wire's tip in the same geometry. That is the intended lesson, not a defect."
16+
}

docs/DEVICES.md

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,71 @@ behaves here. All the guidewires are 0.035" (0.889 mm); the catheter is 5F.
3030
to each other. Their `source` field says so in capitals, and a test asserts they
3131
keep saying so. Replace them with real values when the table is to hand.
3232

33+
### Neurovascular
34+
35+
Everything above is 0.035" peripheral kit. Neuro work is a different scale, and
36+
the difference is **section, not material** — a 0.014" wire is the same nitinol,
37+
six times thinner.
38+
39+
| id | OD | shaft | tip | µ | use |
40+
|---|---|---|---|---|---|
41+
| `microwire_014_soft` | 0.36 mm | 47 | **2** | 0.037 | The exploring microwire. Shapeable 30 mm tip. What you lead with everywhere intracranial. |
42+
| `microwire_014_support` | 0.36 mm | 57 | 6 | 0.08 | Short soft tip on a near-solid shaft. For tracking a microcatheter, not for finding your way. |
43+
| `microcatheter_027` | 0.90 mm | 60 | 6 | 0.037 | 2.7F braided microcatheter. Stated as EI directly — a braid-reinforced composite has no meaningful solid-section modulus. |
44+
| `guide_catheter_6f` | 2.00 mm | 900 | 180 | 0.08 | Holds the arch and takes the reaction while smaller devices work through it. |
45+
46+
The whole class is bounded by arithmetic: a 0.014" round section has
47+
`I = 7.85e-4 mm⁴`, so even **solid** austenitic nitinol at 75 GPa cannot exceed
48+
**59 N·mm²**. A test asserts both microwires stay under it. Everything here is a
49+
SHAPED ESTIMATE — the Harrison table is a 0.035" family and does not reach this
50+
scale, so these are shaped from section geometry and material limits instead.
51+
52+
### What each anatomy will tolerate
53+
54+
Settled peak wall load, measured (`scratch/bench_neuro.py`). Expert peak is
55+
0.54 N, novice 1.75 N, and a healthy wall perforates at 3.8 N.
56+
57+
| device | ICA siphon | M1 stenosis | Type III arch |
58+
|---|---|---|---|
59+
| `microwire_014_soft` | **0.28** | **0.18** | 0.05 |
60+
| `microwire_014_support` | 0.98 | 3.83 ✗ | 0.14 |
61+
| `hydrophilic_angled_035` | 1.30 | 4.33 ✗ | 0.35 |
62+
| `bentson_035` | 1.09 | 5.64 ✗ | 0.38 |
63+
| `amplatz_plain_035` | 9.37 ✗ | 40.1 ✗ | 1.37 |
64+
65+
Read the M1 column. **Every 0.035" wire perforates it**, because a 0.445 mm wire
66+
has no business in a 0.42 mm stenosis — that is clinical truth showing up as
67+
arithmetic, not a defect. And an Amplatz tears the siphon at 9.4 N while a
68+
microwire rests in it at 0.28 N: you do not lead into a siphon with a
69+
peripheral wire.
70+
71+
---
72+
73+
## Coaxial assemblies
74+
75+
Neuro technique is coaxial almost throughout — a wire leads, a catheter follows.
76+
Declare it in the scenario:
77+
78+
```json
79+
"catheter": { "device": "microwire_014_soft", "outer_device": "microcatheter_027", "outer_lag": 0.03 }
80+
```
81+
82+
`outer_lag` is how much bare wire leads the catheter's tip. Over the length they
83+
overlap the two are forced to the same curvature, so **their stiffnesses add**;
84+
beyond the catheter tip you are steering the bare wire. That is the whole reason
85+
the technique works, and why tracking the catheter forward for support and
86+
pulling it back for a soft leading tip are different manoeuvres.
87+
88+
> **Limit.** Coaxial assemblies are exact on straight and gently curved anatomy
89+
> (0.000 N and zero kinked joints on a straight 1.85 mm lumen; 0.575 N settled
90+
> for a 5F over a 0.035" in the arch, inside the Rafii-Tari band). **Driving**
91+
> one through tight curvature coils it — the siphon reaches 1600–2500 N where
92+
> the bare wire reads 0.66 N. That is the geometric-stiffness term `rod.py`
93+
> defers: without it the rod has no compression instability, so where a real
94+
> assembly would prolapse back out of the vessel this one has nowhere to put the
95+
> axial load and knots instead. Pair coaxially in vessels large and gentle
96+
> enough to take it. The arch is; the siphon is not.
97+
3398
---
3499

35100
## Tip shapes
@@ -93,8 +158,10 @@ exerts nothing.
93158

94159
## Choosing
95160

96-
1. **Start soft.** Lead with the floppiest wire that will track. You cannot
97-
perforate with a Bentson in anything the driving test contains.
161+
1. **Start soft.** Lead with the floppiest wire that will track. A Bentson gets
162+
further into the driving test than anything else in the box — though at time
163+
of writing it still perforates at the selection turn, which is a fault in
164+
that course rather than in the wire. See `docs/HANDOFF.md` section 3e.
98165
2. **Match the tip to the lumen** using the table above before blaming the
99166
controls.
100167
3. **Exchange up when you need support**, not before — that is what the Amplatz

0 commit comments

Comments
 (0)