Skip to content

kups.application.simulations.mcmc_rigid

Rigid-body grand-canonical Monte Carlo simulation entry point.

Config

Bases: BaseModel

Top-level configuration for rigid-body MCMC simulations.

Source code in src/kups/application/simulations/mcmc_rigid.py
class Config(BaseModel):
    """Top-level configuration for rigid-body MCMC simulations."""

    adsorbates: tuple[AdsorbateConfig, ...]
    hosts: tuple[HostConfig, ...]
    run: RunConfig
    lj: LJConfig
    ewald: EwaldConfig
    compute_stress: bool = False
    kernel_dtype: Literal["float64", "float32"] = "float64"
    """Precision of the pair kernels and Ewald structure factors; energies are
    summed in float64."""

kernel_dtype = 'float64' class-attribute instance-attribute

Precision of the pair kernels and Ewald structure factors; energies are summed in float64.

EwaldConfig

Bases: BaseModel

Ewald summation configuration.

Source code in src/kups/application/simulations/mcmc_rigid.py
class EwaldConfig(BaseModel):
    """Ewald summation configuration."""

    real_cutoff: float
    precision: float

LJConfig

Bases: BaseModel

Lennard-Jones potential configuration.

Source code in src/kups/application/simulations/mcmc_rigid.py
class LJConfig(BaseModel):
    """Lennard-Jones potential configuration."""

    cutoff: float
    parameters: dict[str, tuple[float | None, float | None]]
    tail_correction: bool
    mixing_rule: MixingRule

    def make_parameters(
        self, labels: tuple[Label, ...]
    ) -> GlobalTailCorrectedLennardJonesParameters:
        """Mix only types present in hosts or insertable molecule templates."""
        required = sorted(set(labels))
        missing = set(required).difference(self.parameters)
        if missing:
            raise ValueError(f"Missing Lennard-Jones parameters for {sorted(missing)}")
        return GlobalTailCorrectedLennardJonesParameters.from_dict(
            cutoff=self.cutoff,
            parameters={label: self.parameters[label] for label in required},
            mixing_rule=self.mixing_rule,
            tail_correction=self.tail_correction,
        )

make_parameters(labels)

Mix only types present in hosts or insertable molecule templates.

Source code in src/kups/application/simulations/mcmc_rigid.py
def make_parameters(
    self, labels: tuple[Label, ...]
) -> GlobalTailCorrectedLennardJonesParameters:
    """Mix only types present in hosts or insertable molecule templates."""
    required = sorted(set(labels))
    missing = set(required).difference(self.parameters)
    if missing:
        raise ValueError(f"Missing Lennard-Jones parameters for {sorted(missing)}")
    return GlobalTailCorrectedLennardJonesParameters.from_dict(
        cutoff=self.cutoff,
        parameters={label: self.parameters[label] for label in required},
        mixing_rule=self.mixing_rule,
        tail_correction=self.tail_correction,
    )

MCMCState

Bases: MCMCStateBase

Rigid-body MCMC state with an initialized persistent pair cache.

Source code in src/kups/application/simulations/mcmc_rigid.py
@dataclass
class MCMCState(MCMCStateBase):
    """Rigid-body MCMC state with an initialized persistent pair cache."""

    cell_tables: FusedPotentialCache[tuple[Index[Label] | Array, ...]]

MCMCStateBase

Shared state for rigid-body MCMC and Widom simulations.

Holds buffered particle/group arrays, motif templates, system thermodynamic data, neighbor lists, potential parameters with caches, and per-move adaptive step-size schedulers.

Source code in src/kups/application/simulations/mcmc_rigid.py
@dataclass
class MCMCStateBase:
    """Shared state for rigid-body MCMC and Widom simulations.

    Holds buffered particle/group arrays, motif templates, system
    thermodynamic data, neighbor lists, potential parameters with
    caches, and per-move adaptive step-size schedulers.
    """

    particles: Buffered[ParticleId, MCMCParticles]
    groups: Buffered[GroupId, MCMCGroup]
    motifs: Table[MotifParticleId, MotifParticles]
    systems: Table[SystemId, MCMCSystems]
    neighborlist_params: UniversalNeighborlistParameters
    lj_parameters: WithCache[
        GlobalTailCorrectedLennardJonesParameters,
        KahanSummand[PotentialOut[EmptyType, EmptyType]],
    ]
    ewald_parameters: WithCache[EwaldParameters, EwaldCache[EmptyType, EmptyType]]
    blocking_spheres_parameters: BlockingSpheresParameters
    blocking_spheres_neighborlist_params: UniversalNeighborlistParameters
    translation_params: Table[SystemId, ParameterSchedulerState]
    rotation_params: Table[SystemId, ParameterSchedulerState]
    reinsertion_params: Table[SystemId, ParameterSchedulerState]
    exchange_params: Table[SystemId, ParameterSchedulerState]

    @property
    def move_capacity(self) -> Capacity[int]:
        """Maximum number of particles per motif (move buffer size)."""
        motif_size = self.motifs.data.motif.max_count
        assert motif_size is not None
        return FixedCapacity(motif_size * len(self.systems))

    @property
    def guest_only(self) -> Self:
        return bind(self, lambda x: x.particles.data).apply(MCMCParticles.guest_only)

    def blocking_spheres_neighborlist(
        self, cutoffs: Table[SystemId, Array]
    ) -> NeighborList[Literal[2]]:
        return DenseNearestNeighborList.new(
            self, lens(lambda x: x.blocking_spheres_neighborlist_params), cutoffs
        )

    @property
    def is_charged(self) -> bool:
        return _is_charged(self.particles, self.motifs)

    @property
    def has_blocking_spheres(self) -> bool:
        return (
            self.blocking_spheres_parameters.system.max_count is not None
            and self.blocking_spheres_parameters.system.max_count > 0
        )

move_capacity property

Maximum number of particles per motif (move buffer size).

MCMCStateUpdate

Proposed particle and group changes for an MCMC move.

Calling an instance applies the update conditionally on accept. With positions_only, only particle positions are written.

Source code in src/kups/application/simulations/mcmc_rigid.py
@dataclass
class MCMCStateUpdate:
    """Proposed particle and group changes for an MCMC move.

    Calling an instance applies the update conditionally on ``accept``.
    With ``positions_only``, only particle positions are written.
    """

    _particles: WithIndices[ParticleId, Buffered[ParticleId, MCMCParticles]]
    groups: WithIndices[GroupId, Buffered[GroupId, MCMCGroup]]
    positions_only: bool = field(static=True, default=False, kw_only=True)

    @staticmethod
    def from_changes(
        key: Array,
        state: MCMCStateBase,
        proposal: ExchangeChanges,
    ) -> MCMCStateUpdate:
        """Build particle and group updates from exchange changes."""
        p_data = proposal.particles.data.data
        g_data = proposal.groups.data.data

        motif_data = state.motifs[p_data.motif]
        new_particles = Buffered(
            proposal.particles.data.keys,
            MCMCParticles(
                positions=p_data.new_positions,
                masses=motif_data.masses,
                atomic_numbers=motif_data.atomic_numbers,
                charges=motif_data.charges,
                labels=motif_data.labels,
                system=p_data.system,
                group=p_data.group,
                motif=p_data.motif,
            ),
            system_view,
        )
        new_groups = Buffered(
            proposal.groups.data.keys,
            MCMCGroup(g_data.system, g_data.motif),
            system_view,
        )
        particle_changes = WithIndices(proposal.particles.indices, new_particles)
        group_changes = WithIndices(proposal.groups.indices, new_groups)

        return MCMCStateUpdate(
            particle_changes, group_changes, positions_only=proposal.positions_only
        )

    def __call__[State: MCMCStateBase](self, state: State, accept: Accept) -> State:
        """Apply the update to ``state``, conditional on ``accept``."""
        acc = Table.broadcast_to(accept, state.systems)
        if self.positions_only:
            new_particles = state.particles.update_if(
                acc,
                self._particles.indices,
                self._particles.data.data,
                only=lambda d: d.positions,
            )
            return bind(state, lambda x: x.particles).set(new_particles)
        new_groups = state.groups.update_if(
            acc, self.groups.indices, self.groups.data.data
        )
        new_particles = state.particles.update_if(
            acc, self._particles.indices, self._particles.data.data
        )
        return bind(state, lambda x: (x.particles, x.groups)).set(
            (new_particles, new_groups)
        )

    @property
    def particles(self) -> WithIndices[ParticleId, MCMCParticles]:
        """Proposed particle data (without the buffer wrapper)."""
        return self._particles.map_data(lambda x: x.data)

particles property

Proposed particle data (without the buffer wrapper).

__call__(state, accept)

Apply the update to state, conditional on accept.

Source code in src/kups/application/simulations/mcmc_rigid.py
def __call__[State: MCMCStateBase](self, state: State, accept: Accept) -> State:
    """Apply the update to ``state``, conditional on ``accept``."""
    acc = Table.broadcast_to(accept, state.systems)
    if self.positions_only:
        new_particles = state.particles.update_if(
            acc,
            self._particles.indices,
            self._particles.data.data,
            only=lambda d: d.positions,
        )
        return bind(state, lambda x: x.particles).set(new_particles)
    new_groups = state.groups.update_if(
        acc, self.groups.indices, self.groups.data.data
    )
    new_particles = state.particles.update_if(
        acc, self._particles.indices, self._particles.data.data
    )
    return bind(state, lambda x: (x.particles, x.groups)).set(
        (new_particles, new_groups)
    )

from_changes(key, state, proposal) staticmethod

Build particle and group updates from exchange changes.

Source code in src/kups/application/simulations/mcmc_rigid.py
@staticmethod
def from_changes(
    key: Array,
    state: MCMCStateBase,
    proposal: ExchangeChanges,
) -> MCMCStateUpdate:
    """Build particle and group updates from exchange changes."""
    p_data = proposal.particles.data.data
    g_data = proposal.groups.data.data

    motif_data = state.motifs[p_data.motif]
    new_particles = Buffered(
        proposal.particles.data.keys,
        MCMCParticles(
            positions=p_data.new_positions,
            masses=motif_data.masses,
            atomic_numbers=motif_data.atomic_numbers,
            charges=motif_data.charges,
            labels=motif_data.labels,
            system=p_data.system,
            group=p_data.group,
            motif=p_data.motif,
        ),
        system_view,
    )
    new_groups = Buffered(
        proposal.groups.data.keys,
        MCMCGroup(g_data.system, g_data.motif),
        system_view,
    )
    particle_changes = WithIndices(proposal.particles.indices, new_particles)
    group_changes = WithIndices(proposal.groups.indices, new_groups)

    return MCMCStateUpdate(
        particle_changes, group_changes, positions_only=proposal.positions_only
    )

init_cell_tables(particles, motifs, systems, lj_parameters, ewald_parameters, dtype=jnp.float64)

Initialize the shared pair cache from the simulation's input data.

Source code in src/kups/application/simulations/mcmc_rigid.py
def init_cell_tables(
    particles: Table[ParticleId, MCMCParticles],
    motifs: Table[MotifParticleId, MotifParticles],
    systems: Table[SystemId, MCMCSystems],
    lj_parameters: GlobalTailCorrectedLennardJonesParameters,
    ewald_parameters: EwaldParameters,
    dtype: DTypeLike = jnp.float64,
) -> FusedPotentialCache[tuple[Index[Label] | Array, ...]]:
    """Initialize the shared pair cache from the simulation's input data."""
    lj: PairEnergy[
        tuple[GlobalTailCorrectedLennardJonesParameters, EwaldParameters],
        MCMCParticles,
        Index[Label],
    ] = lennard_jones_pair.with_particles(lambda p: p).with_parameters(lambda p: p[0])
    parameters = (lj_parameters, ewald_parameters)
    cloud = PointCloud(particles, systems)
    if _is_charged(particles, motifs):
        return FusedPotentialCache.create(
            lj
            + ewald_short_range_pair.with_particles(lambda p: p).with_parameters(
                lambda p: p[1]
            ),
            parameters,
            cloud,
            dtype=dtype,
        )
    return FusedPotentialCache.create(
        PairEnergySum.from_term(lj), parameters, cloud, dtype=dtype
    )

init_state(key, config)

Initialize the full MCMC state from configuration.

Source code in src/kups/application/simulations/mcmc_rigid.py
def init_state(key: Array, config: Config) -> MCMCState:
    """Initialize the full MCMC state from configuration."""
    chain = key_chain(key)
    host_states = [
        mcmc_state_from_config(next(chain), host, config.adsorbates)
        for host in config.hosts
    ]
    if not host_states:
        raise ValueError("At least one host must be provided.")
    motifs = host_states[-1][3]
    particles, groups, system = Table.union(
        [s[0] for s in host_states],
        [s[1] for s in host_states],
        [s[2] for s in host_states],
    )
    logging.info(
        f"Initialized state with {len(particles)} particles, "
        f"{len(groups)} molecules, across {len(system)} systems."
    )
    max_adsorbates = estimate_max_adsorbates(particles, motifs, system)
    if config.run.exchange_prob <= 0:
        # Without insertions no free slots are needed, except one to keep an
        # empty group buffer non-empty.
        free = jnp.zeros_like(max_adsorbates.data)
        if len(groups) == 0:
            free = free.at[0].set(1)
        max_adsorbates = max_adsorbates.set_data(free)
    n_sys = len(system)
    lj_params = config.lj.make_parameters(
        particles.data.labels.keys + motifs.data.labels.keys
    )
    blocking_spheres = BlockingSpheresParameters.from_data(
        [host.blocking_spheres for host in config.hosts]
    )
    max_motif_size = motifs.data.motif.max_count
    assert max_motif_size is not None
    particles, groups, motifs, system = unify_keys_by_cls(
        (particles, groups, motifs, system)
    )
    num_buffer_particles = max_adsorbates * max_motif_size
    particles, groups = add_buffers(
        (particles, int(num_buffer_particles.data.sum())),
        (groups, int(max_adsorbates.data.sum())),
    )

    ewald_params = EwaldParameters.make(
        particles,
        system,
        epsilon_total=config.ewald.precision,
        real_cutoff=config.ewald.real_cutoff,
        reciprocal_dtype=config.kernel_dtype,
    )
    n_kvecs = ewald_params.reciprocal_lattice_shifts.data.shape[1]

    neighborlist_params = UniversalNeighborlistParameters.estimate(
        particles.data.system.counts + num_buffer_particles,
        system,
        tree_map(jnp.maximum, lj_params.cutoff, ewald_params.cutoff),
    )
    if blocking_spheres.radii.shape[0] > 0:
        # Systems without spheres have no radius to size from, so use the batch-wide max.
        max_radius = Table((SystemId(0),), blocking_spheres.radii.max(keepdims=True))
        blocking_nlist = UniversalNeighborlistParameters.estimate(
            particles.data.system.counts + num_buffer_particles,
            system,
            Table.broadcast_to(max_radius, system),
        )
    else:
        blocking_nlist = UniversalNeighborlistParameters(0, 0, 0, 0)
    logging.info(f"Estimated neighbor list parameters: {neighborlist_params}")
    min_half_box = float(system.data.cell.perpendicular_lengths.min() / 2)
    cell_tables = init_cell_tables(
        particles, motifs, system, lj_params, ewald_params, config.kernel_dtype
    )
    return MCMCState(
        particles=particles,
        groups=groups,
        motifs=motifs,
        systems=system,
        neighborlist_params=neighborlist_params,
        blocking_spheres_neighborlist_params=blocking_nlist,
        lj_parameters=WithCache(
            lj_params,
            KahanSummand.init(
                PotentialOut(
                    Table.arange(jnp.zeros(n_sys), label=SystemId), EMPTY, EMPTY
                )
            ),
        ),
        ewald_parameters=WithCache(ewald_params, EwaldCache.make(n_sys, n_kvecs)),
        blocking_spheres_parameters=blocking_spheres,
        translation_params=Table.arange(
            ParameterSchedulerState.create(n_sys, upper_bound=min_half_box),
            label=SystemId,
        ),
        rotation_params=Table.arange(
            ParameterSchedulerState.create(n_sys), label=SystemId
        ),
        reinsertion_params=Table.arange(
            ParameterSchedulerState.create(n_sys), label=SystemId
        ),
        exchange_params=Table.arange(
            ParameterSchedulerState.create(n_sys), label=SystemId
        ),
        cell_tables=cell_tables,
    )

make_guest_stress(state)

Build guest-only stress tensor function from MCMCState.

Source code in src/kups/application/simulations/mcmc_rigid.py
def make_guest_stress(
    state: MCMCState,
) -> StateProperty[MCMCState, Table[SystemId, StressResult]]:
    """Build guest-only stress tensor function from MCMCState."""
    state_lens = identity_lens(MCMCState)
    # The tail correction is deliberately not differentiated here: its energy
    # depends on the cell through the volume, so ``dU_tail/dh`` would add
    # ``(U_tail / V) * I`` to the configurational stress on top of the
    # closed-form ``p_tail`` added below, double-counting it.
    potentials: list[
        Potential[MCMCState, PositionsAndCell, EmptyType, Patch[MCMCState]]
    ] = [
        make_lennard_jones_from_state(state_lens, gradient=POSITIONS_AND_CELL),
    ]
    if state.is_charged:
        potentials.append(
            make_ewald_from_state(
                state_lens,
                gradient=POSITIONS_AND_CELL,
                include_exclusion_mask=True,
            )
        )
    potential = sum_potentials(*potentials)
    potential = CachedPotential(
        potential,
        lens(
            lambda x: PotentialOut(
                x.systems.map_data(lambda s: s.potential_energy.value),
                PositionsAndCell(
                    x.particles.set_data(x.particles.data.position_gradients),
                    x.systems.set_data(x.systems.data.cell_gradients),
                ),
                EMPTY,
            )
        ),
        lambda x: PotentialOut(
            x.systems.index,  # type: ignore
            PositionsAndCell(x.particles.data.system, x.systems.index),  # type: ignore[arg-type]
            EMPTY,
        ),
    )
    propagator = PotentialAsPropagator(potential)

    def stress_fn(key: Array, state: MCMCState) -> Table[SystemId, StressResult]:
        chain = key_chain(key)
        # For stress calculations, we only consider the guest particles, so we take a "guest-only view" of the state.
        guest_only_state = state.guest_only
        state_with_derivatives = propagator(next(chain), guest_only_state)
        config_stress = molecular_virial_stress_from_state(
            next(chain), state_with_derivatives
        )
        p_tail = global_lennard_jones_tail_correction_pressure_from_state(
            next(chain), state
        )
        group_counts = state.groups.data.system.counts
        p_ideal = ideal_gas_pressure(group_counts, state.systems)
        return Table(
            state.systems.keys,
            StressResult(
                potential=config_stress.data,
                tail_correction=p_tail.data[:, None, None] * jnp.eye(3),
                ideal_gas=p_ideal.data[:, None, None] * jnp.eye(3),
            ),
        )

    return stress_fn

make_propagator(state, config)

Build MCMC initialization and cycles.

Source code in src/kups/application/simulations/mcmc_rigid.py
def make_propagator(
    state: MCMCState,
    config: RunConfig,
) -> tuple[Propagator[MCMCState], Propagator[MCMCState]]:
    """Build MCMC initialization and cycles."""
    state_lens = identity_lens(MCMCState)

    potential = fuse_pair_potentials(
        _make_potential(state),
        state,
        lambda s: s.particles,
        lambda s: s.systems,
        state_lens.focus(lambda s: s.cell_tables),
        probe=lambda s, p: p.particles,
        max_queries_per_system=state.motifs.data.motif.max_count,
    )
    potential, probability_ratio = make_muvt_probability_ratio(state_lens, potential)
    propagator = make_gcmc_mcmc_propagator(
        state_lens,
        MCMCStateUpdate.from_changes,
        probability_ratio,
        translation_weight=config.translation_prob,
        rotation_weight=config.rotation_prob,
        reinsertion_weight=config.reinsertion_prob,
        exchange_weight=config.exchange_prob,
    )
    propagator = LoopPropagator(
        propagator,
        lambda x: jnp.maximum(
            x.groups.data.system.counts.data.max(), config.min_cycle_length
        ),
    )
    init_prop = ResetOnErrorPropagator(PotentialAsPropagator(potential))
    return init_prop, ResetOnErrorPropagator(propagator)