Building Your simulator

What is a Simulator?

A simulator is simply a piece of software that takes physics parameters as inputs like \(\delta_{CP}\), \(\Delta m^{2}_{32}\) [2] or the many systematic parameters that make up modern physics models. It then outputs some observable, most commonly some kind of energy spectrum.

MaCh3 as a simulator

MaCh3 is a piece of software used across Neutrino Oscillation experiments used for Bayesian inference. As part of its Posterior likelihood calculation, it generates a set “expected” neutrino event spectra and compares it to some fixed data. For more information see the MaCh3 Wiki.

We can force it to work as a simulator by simply extracting these “expected spectra” at this point and sampling across a large number of points. There is one key caveat to this; neutrino events are independent and identically distributed random variables. As a result, we can model them with a Poisson distribution. When comparing to data in MaCh3 this is fine since we account for this within the likelihood function. For a true simulator though, we also need to simulate this randomness! This is fixed by simply applying Poisson fluctuations to the MaCh3 spectra!

Defining A Simulator

MaCh3 SBI Tools expects a very particular Simulator format as defined in mach3sbitools.simulator.simulator_injector.SimulatorProtocol.

Your “proper” simulator should be written in Python/have python bindings. The interface should then follow this skeleton:

from mach3sbitools.simulator.SimulatorProtocol

# Also need your "proper" simulator
from proper_simulator import ProperSimulator

class MySimulator(SimulatorProtocol):
    def __init__(self, config_file: str):
        # Initialise the actual simulator
        self._proper_simulator = ProperSimulator(config_file)

    def simulate(theta: list[float])->list[float]:
        # Run the actual simulation for example:
        self._proper_simulator.set_values(theta)
        self._proper_simulator.reweight()
        return self._proper_simulator.get_mc()

    def get_parameter_names()->list[str]:
        # Get the parameter names, for example:
        return self._proper_simulator.parameters.get_names()

    def get_parameter_bounds()->list[float], list[float]
        # Get the upper/lower bounds for example:
        lower_bnd = self._proper_simulator.parameters.lower_bounds()
        upper_bnd = self._proper_simulator.parameters.upper_bounds()

        return lower_bnd, upper_bnd

    def get_is_flat(int i)->bool:
        # Holdover from MaCh3 where everything is either flat or Gaussian prior
        # Checks if a given input has a flat prior
        return self._proper_simulator.parameters.is_flat(i)

    def get_data_bins()->list[float]:
        # Get the actual bin heights for data
        return = []
        for s in self._proper_simulator.samples:
            return.extend(s.get_bins()[0])

    def get_parameter_nominals()->list[float]:
        # Get the prior nominal values for each parameter
        return self._proper_simulator.parameters.nominals()

    def get_parameter_errors()->list[float]:
        # Get the prior uncertainties for each parameter
        return self._proper_simulator.parameters.errors()

    def get_covariance_matrix():
        # Get the prior covariance matrix for all parameters
        return self._proper_simulator.parameters.get_cov()

Once this simulator is defined it should be stored in a small python package, for example

my_package
├── my_simulator
└── __init__.py

It can then be used for priors/simulation with Module=my_package.my_simulator and SimulatorClass=MySimulator. For more information about this please see the CLI guide

Note

It is not strictly necessary to inherit from SimulatorProtocol this will just help the linter/your IDE check for any non-implemented methods.