Using a calculator directly#

Monte Carlo simulations in mchammer drive a calculator through an ensemble, which keeps the calculator and the configuration it samples in step. This section describes how to drive a calculator yourself, which is useful when writing a sampling scheme that the shipped ensembles do not cover, such as a kinetic Monte Carlo loop over candidate events.

The calculator holds a configuration#

A ClusterExpansionCalculator holds the occupations of the supercell it was constructed for, initialized from that structure, so every site of that structure has to carry a species the cluster expansion defines. The compiled calculator underneath is reachable as its cpp_calc attribute and exposes the operations below.

set_occupations replaces the held configuration and apply_moves advances it. Evaluation reads the held configuration, so it is not transferred across the interface on every call:

cpp_calc = calculator.cpp_calc
cpp_calc.set_occupations(occupations)     # once per configuration

cluster_vector = cpp_calc.get_cluster_vector()
local = cpp_calc.get_local_cluster_vector(site)

get_cluster_vector also accepts an occupation vector, which evaluates that configuration without adopting it. This is useful for scoring a configuration the calculator should not move to.

Evaluating candidate moves#

A move is an ordered sequence of (site index, new atomic number) pairs, so a single flip is a move of length one and a swap of two sites is a move of length two. Within a move the flips apply in order, so the change of a swap accounts for the first flip when evaluating the second.

get_cluster_vector_changes evaluates any number of moves in one call and returns one change per move:

moves = [[(site_a, species_a)],
         [(site_a, species_a), (site_b, species_b)]]
changes = cpp_calc.get_cluster_vector_changes(moves)
energies = changes @ parameters

Each move is evaluated independently against the held configuration, which the call leaves untouched on every exit path, including when a move is invalid and the call raises partway through. Evaluating candidates therefore never advances the configuration; only apply_moves does that.

Evaluating many candidates in one call rather than one call per candidate is the intended pattern for schemes that score a set of possible events before choosing one.

Accepting a move#

The configuration held by the calculator advances only when told to:

cpp_calc.apply_moves([chosen_move])

At the level of ClusterExpansionCalculator the same step is accept_change(sites, species). A driver that evaluates changes but never accepts them leaves the calculator evaluating against a configuration that no longer matches the one being sampled, which produces wrong energies without raising. A sampling loop written from scratch should therefore compare an accumulated property against a recomputation at intervals, which is what the shipped ensembles do.

Calculators that hold their own state#

A calculator that caches anything derived from the configuration has to keep that cache in step with the configuration through two entry points, and a calculator written from scratch or wrapped around another one has to implement both.

set_occupations moves to a stated configuration. It is absolute, so anything cached has to be rebuilt from the occupations it is given rather than updated. An ensemble calls it once when it starts and again when it restarts from a data container, and the configuration it passes on a restart is generally not reachable from the previous one through accepted changes.

accept_change(sites, species) advances the configuration by a change that has been accepted. It is incremental, so a cache may be advanced rather than rebuilt, which is the point of having one.

Implementing only the second is the failure worth naming, because nothing reports it. Evaluation keeps working and keeps returning numbers, and the numbers are wrong from the first absolute set onwards. This is what ConstituentStrainCalculator has to handle: it stores a structure factor per k-point, reads it as the value before a change rather than recomputing it, and therefore overrides both entry points, rebuilding the structure factors in set_occupations and advancing them in accept_change.

A calculator that wraps or combines others has to forward both to every calculator it holds, for the same reason.

Threading#

The compiled calculator follows the same convention as a standard container. Reading a cluster vector leaves it untouched, so several threads may read from one calculator at the same time. Calls that write to the held occupations need exclusive access, including the batched move evaluation, which applies and reverts them internally. Distinct calculators are independent even when they share a cluster space.

This is a statement about what is safe, not about what runs in parallel. The bindings hold the global interpreter lock for the whole of every call, so two Python threads that read from one calculator take turns rather than running at the same time, and threading a sampling loop written in Python does not make it faster. Use processes for that, giving each its own calculator. The convention matters for callers that reach the C++ class directly and as the guarantee that concurrent reads do not corrupt the held configuration.