diff --git a/lisainstrument/__init__.py b/lisainstrument/__init__.py
index 1781ab0e11e17b9ffccef1516e9ee6a88c3792cb..85367f1c831bc964cf326794bae0676c50ef3dd3 100644
--- a/lisainstrument/__init__.py
+++ b/lisainstrument/__init__.py
@@ -7,3 +7,4 @@ from .meta import __author__
 from .meta import __email__
 
 from .instrument import Instrument
+from .hexagon import Hexagon
diff --git a/lisainstrument/hexagon.py b/lisainstrument/hexagon.py
new file mode 100644
index 0000000000000000000000000000000000000000..22752179c2030b1d98bc797f7bdf51a9bc7c7333
--- /dev/null
+++ b/lisainstrument/hexagon.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Simulation of the Hexagon experiment [1].
+
+References:
+
+    [1] Yamamoto, K., Vorndamme, C., Hartwig, O., Staab, M., Schwarze, T. S., & Heinzel, G. (2021).
+    Experimental verification of intersatellite clock synchronization at LISA performance levels.
+    https://doi.org/10.1103/PhysRevD.105.042009
+
+Authors:
+    Kohei Yamamoto <kohei.yamamoto@aei.mpg.de>
+    Jean-Baptiste Bayle <j2b.bayle@gmail.com>
+"""
+
+import logging
+import numpy as np
+
+from h5py import File
+
+from . import noises
+
+logger = logging.getLogger(__name__)
+
+
+class Hexagon():
+    """Represent the Hexagon instrument.
+
+    Args:
+        size (int): numer of samples in the simulation
+        dt (float): sampling period [s]
+        primary_laser_asd (float): ASD of primary laser 1 [Hz/sqrt(Hz)]
+        locked_laser_asd (float): ASD added to locked lasers 2 & 3 [Hz/sqrt(Hz)]
+        offset_freqs (dict or str): laser frequency offsets [Hz], or 'default'
+        central_freq: central laser frequency [Hz]
+    """
+
+    INDICES = ['1', '2', '3']
+    BEATNOTES = ['12', '23', '31']
+
+    def __init__(self,
+                 size=100,
+                 dt=1 / (80E6 / 2**17 / 10 / 6 / 3),
+                 primary_laser_asd=100,
+                 locked_laser_asd=60,
+                 offset_freqs='default',
+                 central_freq=2.816E14):
+
+        self.simulated = False
+
+        self.size = int(size)
+        self.dt = float(dt)
+        self.fs = 1.0 / self.dt
+        self.duration = self.size * self.dt
+        self.time = np.arange(self.size) * self.dt
+
+        logger.info("Initialize hexagon experiment (size=%d, dt=%f, duration=%f",
+            self.size, self.dt, self.duration)
+
+        self.primary_laser_asd = float(primary_laser_asd)
+        self.locked_laser_asd = float(locked_laser_asd)
+
+        self.central_freq = float(central_freq)
+        if offset_freqs == 'default':
+            logger.debug("Use default set of offset frequencies")
+            self.offset_freqs = {'1': 0.0, '2': 15E6, '3': 7E6}
+        else:
+            self.offset_freqs = offset_freqs
+
+        # Initialize single-laser-related attributes
+        self.laser_noises = None
+        self.carrier_offsets = None
+        self.carrier_fluctuations = None
+        # Initialize beatnote-related attributes
+        self.carrier_beatnotes = None
+        self.carrier_beatnote_offsets = None
+        self.carrier_beatnote_fluctuations = None
+        # Initialize signal-combination attributes
+        self.three_signal_combination = None
+
+    def simulate(self):
+        """Run a simulation, and generate all intermediary signals."""
+
+        logger.info("Starting simulation")
+        if self.simulated:
+            logger.warning("Overwriting previous simulated values")
+
+        # Laser noise
+        logger.debug("Generating laser noise")
+        self.laser_noises = np.empty((self.size, 3)) # (size, laser) [Hz]
+        # Laser 1 has its own stability
+        self.laser_noises[:, 0] = noises.laser(self.fs, self.size, self.primary_laser_asd)
+        # Laser 2 replicated laser 1 with added locking noise
+        self.laser_noises[:, 1] = self.laser_noises[:, 0] \
+            + noises.white(self.fs, self.size, self.locked_laser_asd)
+        # Laser 3 replicated laser 1 with added locking noise
+        self.laser_noises[:, 2] = self.laser_noises[:, 0] \
+            + noises.white(self.fs, self.size, self.locked_laser_asd)
+
+        # Carrier beams
+        logger.debug("Simulating carrier beams")
+        self.carrier_fluctuations = self.laser_noises # (size, laser) [Hz]
+        self.carrier_offsets = np.array(
+            [[self.offset_freqs[index] for index in self.INDICES]]
+        ) # (size, laser) [Hz]
+
+        # Compute beatnotes
+        # Convention is from paper: beatnote ij is beam j - beam i
+        logger.debug("Computing carrier beatnotes")
+        self.carrier_beatnote_offsets = np.stack([
+            self.carrier_offsets[:, int(ij[1]) - 1] - self.carrier_offsets[:, int(ij[0]) - 1]
+            for ij in self.BEATNOTES
+        ], axis=-1) # (size, beatnote) [Hz]
+        self.carrier_beatnote_fluctuations = np.stack([
+            self.carrier_fluctuations[:, int(ij[1]) - 1] - self.carrier_fluctuations[:, int(ij[0]) - 1]
+            for ij in self.BEATNOTES
+        ], axis=-1) # (size, beatnote) [Hz]
+        self.carrier_beatnotes = self.carrier_beatnote_offsets + self.carrier_beatnote_fluctuations
+
+        # Three-signal combination
+        logger.debug("Forming three-signal combination")
+        self.three_signal_combination = self.carrier_beatnotes[:,0] \
+            + self.carrier_beatnotes[:,1] + self.carrier_beatnotes[:,2]
+
+        logger.info("Simulation complete")
+        self.simulated = True
+
+    def write(self, output='measurements.h5', mode='w'):
+        """Write simulation results.
+
+        Args:
+            output: path to measurement file
+            mode: measurement file opening mode
+        """
+        # Run simulation is needed
+        if not self.simulated:
+            logger.debug("No simulated data, running new simulation")
+            self.simulate()
+
+        logger.info("Writing simulated data to '%s'", output)
+        logger.debug("Opening file '%s' (mode='%s')", output, mode)
+        with File(output, mode) as hdf5:
+
+            logger.debug("Saving simulation parameters as metadata")
+
+            hdf5.attrs['size'] = self.size
+            hdf5.attrs['dt'] = self.dt
+            hdf5.attrs['fs'] = self.fs
+            hdf5.attrs['duration'] = self.duration
+
+            hdf5.attrs['primary_laser_asd'] = self.primary_laser_asd
+            hdf5.attrs['locked_laser_asd'] = self.locked_laser_asd
+            hdf5.attrs['offset_freqs'] = str(self.offset_freqs)
+            hdf5.attrs['central_freq'] = self.central_freq
+
+            logger.debug("Saving simulated data")
+
+            self._write_dataset(hdf5, 'laser_noises', indices=self.INDICES)
+            self._write_dataset(hdf5, 'carrier_offsets', indices=self.INDICES)
+            self._write_dataset(hdf5, 'carrier_fluctuations', indices=self.INDICES)
+            self._write_dataset(hdf5, 'carrier_beatnotes', indices=self.BEATNOTES)
+            self._write_dataset(hdf5, 'carrier_beatnote_offsets', indices=self.BEATNOTES)
+            self._write_dataset(hdf5, 'carrier_beatnote_fluctuations', indices=self.BEATNOTES)
+            self._write_dataset(hdf5, 'three_signal_combination')
+
+    def _write_dataset(self, hdf5, data, indices=None):
+        """Write a single object attribute on ``hdf5``.
+
+        Args:
+            hdf5 (:obj:`h5py.Group`): an HDF5 group or file
+            data (array-like): data to write
+            indices (list of str): index list, ``None`` if no indices
+        """
+        # Write target attribute
+        hdf5[data] = getattr(self, data)
+        # Write dimension scales
+        if indices is not None:
+            hdf5[data].attrs['indices'] = indices
diff --git a/tests/test_hexagon.py b/tests/test_hexagon.py
new file mode 100755
index 0000000000000000000000000000000000000000..6c7fbe50d706eb414003f3f1865891c5706a5fc6
--- /dev/null
+++ b/tests/test_hexagon.py
@@ -0,0 +1,20 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+# pylint: disable=missing-module-docstring
+
+import os
+from lisainstrument import Hexagon
+
+
+def test_run():
+    """Test that simulations can run."""
+    hexagon = Hexagon()
+    hexagon.simulate()
+
+def test_write():
+    """Test that simulations can run and be written."""
+    hexagon = Hexagon()
+    hexagon.simulate()
+    hexagon.write('test.h5', mode='w')
+
+    assert os.path.isfile('test.h5')