Discrete-velocity Boltzmann gas of Carleman
The Carleman discrete-velocity Boltzmann equation on a periodic line, from a pulse of right movers.
- Name:
discrete_velocity_boltzmann - Field:
physics/fluids - Formalism: ordinary differential equations
- References: Carleman 1957; Gatignol 1975; Bellomo et al. 2017
Factory
@register_model(
field='physics/fluids',
tags=('field', 'kinetic theory', 'discrete velocities', 'finite volume', 'pde'),
references=('Carleman 1957', 'Gatignol 1975', 'Bellomo et al. 2017'),
defaults={'until': 5.0, 'dt': 0.1, 'solver': SSPRKSolver()},
)
def discrete_velocity_boltzmann(
n: int = 200,
*,
c: float = 1.0,
kappa: float = 1.0,
length: float = 10.0,
) -> System:
"""Return the Carleman discrete-velocity Boltzmann equation on a periodic line, from a pulse of right movers.
A pulse of right-moving particles on a uniform background splits into two moving pulses while collisions relax
every site towards equal densities, and the total mass stays constant.
Args:
n:
The number of cells.
c:
The particle speed.
kappa:
The collision rate.
length:
The length of the line.
Returns:
system:
The model, with the densities `gas.f_plus` and `gas.f_minus`.
Examples:
>>> from skcomplex.models.physics.fluids import discrete_velocity_boltzmann
>>> discrete_velocity_boltzmann(16).elements[0].check().prepare().size_
16
"""
spacing = length / n
centers = (np.arange(n) + 0.5) * spacing
pulse = np.exp(-((centers - length / 2) ** 2))
values = {'f_plus': 0.5 + pulse, 'f_minus': np.full(n, 0.5), 'c': c, 'kappa': kappa}
space = LatticeSpace(n, spacing=spacing)
return System(
elements=[
FieldElement('gas', element=CarlemanGasElement, space=space, values=values, discretization='finite-volume'),
],
interactions=[
ConservationInteraction(
'right_movers',
target='gas',
state='f_plus',
flux=lambda s: s.target.c * s.target.f_plus,
source=_get_collisions,
),
ConservationInteraction(
'left_movers',
target='gas',
state='f_minus',
flux=lambda s: -s.target.c * s.target.f_minus,
source=lambda s: -_get_collisions(s),
),
],
)
Components
class CarlemanGasElement(Element):
"""Site of the Carleman discrete-velocity gas, densities `f_plus` and `f_minus` moving at the speeds `c` and `-c`.
Collisions turn two particles of one velocity into two of the other at the rate `kappa`, so
`df_plus/dt + c df_plus/dx = kappa (f_minus^2 - f_plus^2)` and the opposite for `f_minus`, which conserves the
mass and relaxes to the equilibrium `f_plus = f_minus`.
Args:
label:
The label of the site or the field.
visual:
How the element is drawn, or `None` for `default_visual_`.
f_plus:
The initial density of right-moving particles.
f_minus:
The initial density of left-moving particles.
c:
The particle speed.
kappa:
The collision rate.
Examples:
>>> from skcomplex.models.physics.fluids import CarlemanGasElement
>>> CarlemanGasElement('site').kappa
1.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
f_plus: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
f_minus: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
c: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
kappa: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
) -> None:
super().__init__(label, visual=visual)
self.f_plus = f_plus
self.f_minus = f_minus
self.c = c
self.kappa = kappa
class ConservationInteraction(Interaction):
"""Conservation law `du/dt + divergence(flux(ns)) = source(ns)` of a state `u` of a field.
The flux is one expression for a field of one dimension, or one expression per dimension, of the states of the
field, and the interaction lowers it to a finite-volume flux law with a slope limiter and a numerical flux, or,
without a numerical flux, to the rate `-divergence(flux)` that the field discretizes by its own operators, as a
spectral or finite-difference field does. Without a source the total of the state on a periodic field stays
constant. The conservation laws of several states of one field form one system, whose numerical flux reads the
wave speeds of all of them, and a flux may read parameters that vary across the cells. A flux or a source function
takes the namespace of the interaction, with the namespace `target` of the field, the time `t`, and the symbols of
the parameters a subclass declares.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the field whose state is conserved.
state:
The name of the conserved state of the field.
flux:
A function of the namespace that returns the flux, or one flux per dimension.
source:
A function of the namespace that returns the source, or `None` for none.
limiter:
The slope limiter of the reconstruction, `'minmod'`, `'van-leer'`, or `None` for first order.
riemann:
The numerical flux of a finite-volume field, `'rusanov'` or `'godunov'`, the exact solver for one state, or
`None` for the divergence of the flux on a field of another discretization.
sources:
The paths of other elements the flux and the source read, as the namespaces `sources`. A field over the
position lattice of a target on a phase space is read broadcast along the class axes.
Raises:
ValidationError:
The state is not a name, a function is not callable, or the limiter or the numerical flux is unknown.
Examples:
>>> from typing import Annotated
>>> import numpy as np
>>> from skcomplex.core.elements import Element, FieldElement
>>> from skcomplex.core.interactions import ConservationInteraction
>>> from skcomplex.core.spaces import LatticeSpace
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> from skcomplex.specs import StateSpec
>>> class CellElement(Element):
... def __init__(self, label, u: Annotated[float, StateSpec()] = 0.0):
... super().__init__(label)
... self.u = u
>>> space = LatticeSpace((50,), spacing=0.1, boundary='periodic')
>>> values = {'u': 1.0 + np.sin(2 * np.pi * np.arange(50) / 50)}
>>> volumes = 'finite-volume'
>>> field = FieldElement('wave', element=CellElement, space=space, values=values, discretization=volumes)
>>> burgers = ConservationInteraction('burgers', target='wave', state='u', flux=lambda s: s.target.u**2 / 2)
>>> system = System(elements=[field], interactions=[burgers])
>>> trajectory = Simulation(system, dt=0.01).check().prepare().run(until=0.5).trajectory_
>>> round(float(trajectory.values('wave.u')[-1].sum()), 6)
50.0
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
state: str = 'u',
flux: Callable[[SimpleNamespace], object] | None = None,
source: Callable[[SimpleNamespace], object] | None = None,
limiter: str | None = 'minmod',
riemann: str | None = 'rusanov',
sources: Annotated[Sequence[str], RoleSpec(variadic=True)] = (),
) -> None:
super().__init__(label)
self.target = target
self.state = state
self.flux = flux
self.source = source
self.limiter = limiter
self.riemann = riemann
self.sources = sources
def _check(self) -> None:
"""Raise an error for a state that is not a name, a function that is not callable, or an unknown scheme.
Raises:
ValidationError:
The type declares states, the state is not a name, a function is not callable, or the limiter or the
numerical flux is unknown.
"""
super()._check()
if not isinstance(self.state, str) or not self.state.isidentifier():
error_msg = f'{type(self).__name__} {self.label!r} conserves the state {self.state!r}, which is no name.'
raise ValidationError(error_msg)
for name in ('flux', 'source'):
value = getattr(self, name)
if value is not None and not callable(value):
error_msg = f'The {name} of {self.label!r} is {value!r}. Give a function of the namespace.'
raise ValidationError(error_msg)
if self.limiter not in LIMITERS:
error_msg = f'Unknown limiter {self.limiter!r} of {self.label!r}. Use minmod, van-leer, or None.'
raise ValidationError(error_msg)
if self.riemann not in RIEMANN_SOLVERS:
error_msg = f'Unknown numerical flux {self.riemann!r} of {self.label!r}. Use rusanov, godunov, or None.'
raise ValidationError(error_msg)
def _get_flux(self, namespace: SimpleNamespace) -> object:
"""Return the flux, or `None` for none, the hook a subclass overrides to derive it from its arguments."""
return None if self.flux is None else self.flux(namespace)
def _get_transport_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the laws of the divergence of the flux, the hook a subclass overrides to lower its own flux."""
flux = self._get_flux(namespace)
if flux is None:
return []
state = getattr(self._get_member(namespace, 'target'), self.state)
if self.riemann is None:
components = list(flux) if isinstance(flux, FreeModuleElement | list | tuple) else [flux]
return [RateLaw(state, -divergence(components))]
return [FluxLaw(state, flux, limiter=self.limiter, riemann=self.riemann)]
def _get_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the laws of the divergence of the flux and the rate of the source.
Args:
namespace:
The namespace of the symbols the laws can use, with the namespace of the field.
Returns:
laws:
The flux law, and the rate law of the source when there is one.
"""
laws = self._get_transport_laws(namespace)
if self.source is not None:
laws.append(RateLaw(getattr(self._get_member(namespace, 'target'), self.state), self.source(namespace)))
return laws
Equations
gas.f_plus[i]: diff(f_plus_gas(t), t) == (f_minus_gas(t)^2 - f_plus_gas(t)^2)*kappa_gas
gas.f_minus[i]: diff(f_minus_gas(t), t) == -(f_minus_gas(t)^2 - f_plus_gas(t)^2)*kappa_gas