solver¶
Low-level ODE integration primitives. Most users should prefer run_simulation from jbubble.simulation, which wraps these with post-processing. Use solve_eom directly when you need access to the raw diffrax.Solution object.
from jbubble import solve_eom, SaveSpec, SolverConfig
SaveSpec
¶
SaveSpec(num_samples: int = 1024)
Bases: Module
Specification for ODE output sampling.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
num_samples
|
int
|
Number of evenly spaced time points to record. Default: |
1024
|
build
¶
Return a diffrax.SaveAt with num_samples times from t0 to t1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t0
|
Array
|
First saved time [s]. |
required |
t1
|
Array
|
Last saved time [s]. |
required |
Returns:
| Type | Description |
|---|---|
SaveAt
|
Save specification for |
Source code in jbubble/solver.py
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | |
SolverConfig
¶
SolverConfig(
solver: AbstractSolver = Dopri5(),
stepsize_controller: AbstractStepSizeController = diffrax.PIDController(
rtol=_DEFAULT_RTOL, atol=_DEFAULT_ATOL
),
dt0: float = 1e-09,
max_steps: int = 100000,
)
Bases: Module
Numerical integration settings for solve_eom.
The defaults suit microbubbles in water: an explicit fifth-order
Runge-Kutta method (diffrax.Dopri5) with an adaptive step size. For
stiff problems, use SolverConfig.stiff.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
solver
|
AbstractSolver
|
ODE solver. Default: |
Dopri5()
|
stepsize_controller
|
AbstractStepSizeController
|
Step-size controller. Default:
|
diffrax.PIDController(rtol=_DEFAULT_RTOL, atol=_DEFAULT_ATOL)
|
dt0
|
float
|
Initial step size [s]. Default: |
1e-09
|
max_steps
|
int
|
Maximum number of solver steps per integration. Default: |
100000
|
Notes
Choosing tolerances. At the defaults, a microbubble driven at
100 kPa has radius errors of \(10^{-6}\) to \(10^{-5} R_0\) and relative
gradient errors of \(10^{-5}\) to \(10^{-3}\). The kink in a Marmottant
surface-tension law is the hardest case, with radius errors up to about
\(10^{-3} R_0\) and gradient errors of a few percent, or about 12 % for the
peak radius of the lipid_bubble preset with respect to kappa_s. Use
tighter tolerances when you fit a lipid shell. Through an
inertial collapse (\(R_\text{max}/R_0 \approx 4\)), both errors are about
\(10^{-3}\) to \(10^{-2}\). rtol=1e-8, atol=1e-12 makes them 100 to 1000
times smaller for two to three times as many steps. Looser tolerances
can make gradients through a collapse wrong by more than 100 %.
rtol sets the accuracy of most solves. atol matters where a scaled
state component is far below 1: the wall velocity of a small or weakly
driven bubble, which can be millimetres per second. The default
velocity tolerance, about \(10^{-9}\) m/s, matches jbubble 0.1. Unlike in
0.1, where atol=1e-9 applied in metres, the radius tolerance is
relative to \(R_0\), so a collapsing microbubble no longer has a loose
radius tolerance: through an inertial collapse, 0.1's gradients could be
off by 50 % or more.
Stiffness. A bubble is stiff when its fastest relaxation rate is much larger than the driving angular frequency \(\omega\). For a viscous liquid and a lipid shell of dilatational viscosity \(\kappa_s\), that rate is about
divided by \(1 + (4\mu + 4\kappa_s/R_0)/(\rho_L c_L R_0)\) for Keller-Miksis. The problem is stiff when \(\lambda/\omega \gtrsim 100\). For example, a lipid shell with \(\kappa_s = 7.5 \times 10^{-9}\) kg/s driven at 5 MHz gives \(\lambda/\omega \approx 850\) at 50 nm, 320 at 100 nm, and 150 at 150 nm, and a 300 nm bubble in a 0.05 Pa s liquid at 2 MHz gives 120. A 2 µm lipid-coated microbubble at 1 MHz gives 0.8.
On a stiff problem, Dopri5 steps at the edge of its stability region:
it needs hundreds to thousands of steps per driving period, and
although the radius stays accurate, at the default tolerances the
gradient through the solve can be wrong by orders of magnitude, or NaN,
even when the solve reports converged = True: a 20 nm lipid bubble
driven at 5 MHz gives a NaN gradient. Use
stiff there.
Long integrations. Dopri5 needs about 60 to 100 steps, accepted
and rejected, per driving period for a microbubble in water, so
max_steps=100_000 covers about a thousand periods. Raise it for
longer pulses. A larger max_steps costs neither memory nor compile
time under jax.grad.
stiff
classmethod
¶
stiff(
*,
rtol: float = 1e-06,
atol: float = 1e-10,
dt0: float = 1e-09,
max_steps: int = 100000,
) -> SolverConfig
Return settings for stiff problems: an implicit Kvaerno5 solver.
Use this configuration for nanobubbles, sub-micron bubbles in
viscous liquids, and other stiff problems (see the Notes of
SolverConfig). It returns
SolverConfig(
solver=diffrax.Kvaerno5(
root_finder=optimistix.Chord(
rtol=rtol, atol=max(atol, 1e-6), norm=optimistix.rms_norm
)
),
stepsize_controller=diffrax.PIDController(rtol=rtol, atol=atol),
dt0=dt0,
max_steps=max_steps,
)
Two choices make the implicit solver reliable:
- The
Chordroot finder.Kvaerno5's default root finder,diffrax.VeryChord, reports a Newton iteration that converges slowly as diverged in diffrax 0.7.2 with optimistix 0.1.0, so the solver rejects many steps: on a moderately stiff or a non-stiff problem it takes 6 to 13 times more steps than withChord. Diffrax pull request 754 fixes this, but no release contains it yet. - A floor on the Newton tolerance.
Kvaerno5solves for the derivative of the scaled state at each stage, andChordcompares the residual of those equations, in units of the scaled state per second, with its absolute tolerance alone. A nanobubble's right-hand side is a sum of large terms that cancel, so roundoff keeps that residual above about1e-8. With a smaller tolerance, the solver rejects most steps of a 10 to 40 nm bubble and stops atmax_steps. The Newton iterations therefore use an absolute tolerance of at least1e-6.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rtol
|
float
|
Relative tolerance of the step-size controller and of the Newton
iterations. Default: |
1e-06
|
atol
|
float
|
Absolute tolerance of the step-size controller, on the scaled
state (see |
1e-10
|
dt0
|
float
|
Initial step size [s]. Default: |
1e-09
|
max_steps
|
int
|
Maximum number of solver steps. Default: |
100000
|
Returns:
| Type | Description |
|---|---|
SolverConfig
|
Implicit-solver settings. |
Notes
On a stiff problem, Kvaerno5 takes 5 to 50 times fewer steps than
Dopri5, or converges where Dopri5 reaches max_steps, and its
gradients are accurate. For 20 to 150 nm lipid bubbles at the
default tolerances, the gradient of a loss on the deviation
\(R/R_0 - 1\) with respect to the shell viscosity has a relative error
of about \(10^{-5}\) to \(10^{-4}\). A loss dominated by the mean radius,
such as one on \(R/R_0\), has larger errors: about \(10^{-3}\) at 20 nm
and \(10^{-2}\) at 10 nm. On a problem that isn't stiff, it takes
about as many steps as Dopri5, with similar errors, but runs 5 to
10 times longer. It also compiles more slowly: about 2 to 6 s for a
solve, against about 1 s or less for Dopri5.
To change the tolerances, call stiff again with new rtol and
atol, rather than replacing stepsize_controller, so that the
Newton tolerances follow.
jax.jit, jax.vmap, and jax.grad work with this configuration.
jax.pmap doesn't: with diffrax 0.7.2, an implicit solve under
jax.pmap raises ValueError: pytree does not match out_structure.
Examples:
from jbubble import SolverConfig, run_simulation
result = run_simulation(eom, pulse, config=SolverConfig.stiff())
Source code in jbubble/solver.py
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 | |
solve_eom
¶
solve_eom(
eom: EquationOfMotion,
pulse: Pulse,
*,
y0: Any = None,
t_max: ArrayLike | None = None,
save_spec: SaveSpec | None = None,
config: SolverConfig | None = None,
adjoint: AbstractAdjoint | None = None,
progress: bool = False,
) -> Solution
Solve the bubble dynamics for an equation of motion.
Integrates the initial value problem
The solver works on the scaled state state / eom.state_scale(y0), so
the tolerances in config are relative to R0 for the radius and to
\(\sqrt{P_\text{amb}/\rho_L}\) for the wall velocity, and returns the
solution in SI units. It evaluates the equation of motion only at
admissible states (see
EquationOfMotion.is_admissible);
elsewhere the right-hand side is NaN, so the adaptive solver rejects
the trial step. Rejected steps never enter the solution, so the guard
keeps gradients finite without changing the forward solve: with an
explicit solver, such as the default Dopri5, the solution is the same,
bit for bit, as without the guard. With an implicit solver, XLA can
round the Newton iterations differently in the last bit, so the
solution agrees to within the solver tolerance.
The integration doesn't raise when the solver fails (throw=False);
check diffrax.is_successful on the solution's result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
eom
|
EquationOfMotion
|
Assembled equation of motion, such as
|
required |
pulse
|
Pulse
|
Driving acoustic pulse. An adaptive step-size controller steps to
every time in
|
required |
y0
|
BubbleState
|
Initial state in SI units. |
None
|
t_max
|
float
|
Integration end time [s]. |
None
|
save_spec
|
SaveSpec
|
Output sampling specification. |
None
|
config
|
SolverConfig
|
Numerical integration settings. |
None
|
adjoint
|
AbstractAdjoint
|
How |
None
|
progress
|
bool
|
Whether to show a text progress meter. Default: |
False
|
Returns:
| Type | Description |
|---|---|
Solution
|
Solution object with |
Source code in jbubble/solver.py
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 | |
Solver choice and tolerances¶
For a longer explanation, with examples, see the guide Solvers and stiffness.
The default, SolverConfig(), is Dopri5 (an explicit fifth-order Runge–Kutta method) with a PID step-size controller at rtol=1e-6 and atol=1e-10, an initial step of 1 ns, and at most 100,000 steps. It suits microbubbles in water, including inertial collapse.
The solver integrates a dimensionless state: the radius in units of \(R_0\) and the wall velocity in units of \(\sqrt{P_\text{amb}/\rho_L}\). So atol=1e-10 means \(10^{-10} R_0\) for a bubble of any size. In jbubble 0.1, atol applied in SI units.
For stiff problems, such as lipid-coated nanobubbles or sub-micron bubbles in viscous liquids, use the implicit configuration:
from jbubble import SolverConfig, run_simulation
result = run_simulation(eom, pulse, config=SolverConfig.stiff())
The Notes of SolverConfig explain how to tell whether a problem is stiff.
GridSweep runs implicit solvers such as Kvaerno5 on its worker threads, like explicit ones.
To tighten the tolerances, pass them to the constructor you use, for example SolverConfig.stiff(rtol=1e-8, atol=1e-12), or SolverConfig(stepsize_controller=diffrax.PIDController(rtol=1e-8, atol=1e-12)). If result.converged is False because the solve reached max_steps, raise max_steps, or switch to SolverConfig.stiff() if Dopri5 needs thousands of steps per driving period.
For gradients, keep the default adjoint, diffrax.RecursiveCheckpointAdjoint(), which differentiates the discretised solve exactly. Pass adjoint=diffrax.ForwardMode() to run_simulation or solve_eom for forward-mode Jacobians, for example in a Levenberg–Marquardt fit.