diff --git a/phiphi/bnoc_lines.py b/phiphi/bnoc_lines.py
new file mode 100644
index 0000000000000000000000000000000000000000..f029ab7f982a8d752c5db3a56ba2d839010e3404
--- /dev/null
+++ b/phiphi/bnoc_lines.py
@@ -0,0 +1,10 @@
+# From Matthew Monk's AProd
+# put on it's own because needs to be imported at hlt but bnoc_utils.py has
+# FunTuple imports etc which make it complicated
+event_type_to_hlt2_lines = {
+    "13104012": ["Hlt2BnoC_BdsToPhiPhi"],
+    "11104022": ["Hlt2BnoC_BdsToPhiPhi"],
+    "15104020": ["Hlt2BnoC_BdsToPhiPhi"],
+    "13104021": ["Hlt2BnoC_BdsToPhiPhi"],
+    "13104016": ["Hlt2BnoC_BdsToPhiPhi"]
+}
\ No newline at end of file
diff --git a/phiphi/bnoc_utils.py b/phiphi/bnoc_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..90802bb3fbdd0570bfea729110b2940ac8f230d0
--- /dev/null
+++ b/phiphi/bnoc_utils.py
@@ -0,0 +1,352 @@
+import Functors as F
+from FunTuple import FunctorCollection, FunTuple_Particles as FunTuple
+from DaVinciMCTools import MCTruthAndBkgCat
+from PyConf.reading import get_pvs
+from RecoConf.event_filters import require_pvs
+from FunTuple.functorcollections import (
+    DecayTreeFitterResults,
+    Kinematics,
+    ParticleID,
+    EventInfo,
+    HltTisTos,
+)
+
+
+def make_require_pvs():
+    pvs = get_pvs()
+    return require_pvs(pvs)
+
+
+def make_res(indices, names):
+    name = "".join(names)
+    res_fc = FunctorCollection({
+        f"{name}_M":
+        F.SUBCOMB(Functor=F.MASS, Indices=indices),
+        f"{name}_PE":
+        F.SUBCOMB(Functor=F.SUM(F.ENERGY), Indices=indices),
+        f"{name}_PX":
+        F.SUBCOMB(Functor=F.SUM(F.PX), Indices=indices),
+        f"{name}_PY":
+        F.SUBCOMB(Functor=F.SUM(F.PY), Indices=indices),
+        f"{name}_PZ":
+        F.SUBCOMB(Functor=F.SUM(F.PZ), Indices=indices),
+        f"{name}_PT":
+        F.SUBCOMB(Functor=F.SUM(F.PT), Indices=indices),
+    })
+    return res_fc
+
+
+fps_4h_to_pid = {
+    "Kp": "K+",
+    "Km": "K-",
+    "Pip": "pi+",
+    "Pim": "pi-",
+}
+
+vv_lines = {
+    "BdsToKstzKstzb": {
+        "desc":
+        "${B}[B_s0 -> ${Kst}(K*(892)0 -> ${Kp}K+ ${pim}pi-) ${Kstb}(K*(892)~0 -> ${Km}K- ${pip}pi+)]CC",
+        "mass_constraints": []
+    },
+    "BdsToKstzPhi": {
+        "desc":
+        "${B}[B0 -> ${phi}(phi(1020) -> ${Kp1}K+ ${Km_1}K- ) ${Kst}(K*(892)0 -> ${Kp_2}K+ ${pim_2}pi-)]CC",
+        "mass_constraints": [["_phi", ["phi(1020)"]]]
+    },
+    "BdsToKstzRho": {
+        "desc":
+        "${B}[B0 -> ${Kst}(K*(892)~0 -> ${Km}K- ${pip}pi+) ${Rho}(rho(770)0 -> ${pip2}pi+ ${pim2}pi-)]CC",
+        "mass_constraints": []
+    },
+    "BdsToPhiRho": {
+        "desc":
+        "${B}[B_s0 -> ${phi}(phi(1020) -> ${Kp}K+ ${Km}K-) ${Rho}(rho(770)0 -> ${pip}pi+ ${pim}pi-)]CC",
+        "mass_constraints": [["_phi", ["phi(1020)"]]]
+    },
+    "BdsToRhoRho": {
+        "desc":
+        "${B}[B0 -> ${Rho_1}(rho(770)0 -> ${pip_1}pi+ ${pim1}pi-) ${Rho_2}(rho(770)0 -> ${pip_2}pi+ ${pim_2}pi-)]CC",
+        "mass_constraints": []
+    },
+    "BdsToPhiPhi": {
+        "desc":
+        "${B_s0}[B_s0 -> ${phi_1}(phi(1020) -> ${Kp1}K+ ${Km1}K-) ${phi_2}(phi(1020) -> ${Kp2}K+ ${Km2}K-)]CC",
+        "mass_constraints": [["_phi", ["phi(1020)"]]]
+    },
+}
+
+hhhh_lines = {
+    "BdsToKpKmKpKm": {
+        "desc": "${B}B0 -> ${Kp1}K+ ${Km1}K- ${Kp2}K+ ${Km2}K-",
+        "combinations": [(1, 2), (3, 4), (1, 2, 3)]
+    },
+    "BdsToKpKmKpPim": {
+        "desc": "${B}B0 -> ${Kp1}K+ ${Km1}K- ${Kp2}K+ ${pim2}pi-",
+        "combinations": [(1, 2), (3, 4), (1, 2, 3), (2, 3, 4)]
+    },
+    "BdsToKpKmPipPim": {
+        "desc": "${B}B0 -> ${Kp1}K+ ${Km1}K- ${pip2}pi+ ${pim2}pi-",
+        "combinations": [(1, 2), (3, 4), (1, 4), (2, 3), (1, 2, 3), (2, 3, 4)]
+    },
+    "BdsToKpPimPipPim": {
+        "desc": "${B}B0 -> ${Kp1}K+ ${pim1}pi- ${pip2}pi+ ${pim2}pi-",
+        "combinations": [(1, 2), (3, 4), (1, 2, 3), (2, 3, 4)]
+    },
+    "BdsToPipPimPipPim": {
+        "desc": "${B}B0 -> ${pip1}pi+ ${pim1}pi- ${pip2}pi+ ${pim2}pi-",
+        "combinations": [(1, 2), (3, 4), (1, 2, 3)]
+    },
+}
+
+
+Hlt1_decisions = [
+    # main hadronic triggers for TOS
+    "Hlt1TrackMVA",
+    "Hlt1TwoTrackMVA",
+    "Hlt1TwoTrackMVACharmXSec",
+    "Hlt1TwoTrackKs",
+    "Hlt1KsToPiPi",
+    "Hlt1GECPassthrough"
+    # additional triggers for checking TIS signal
+    "Hlt1D2KK",
+    "Hlt1D2KPi",
+    "Hlt1D2PiPi",
+    "Hlt1DiMuonHighMass",
+    "Hlt1DiMuonLowMass",
+    "Hlt1DiMuonSoft",
+    "Hlt1LowPtMuon",
+    "Hlt1LowPtDiMuon",
+    "Hlt1SingleHighPtMuon",
+    "Hlt1TrackMuonMVA",
+]
+
+
+
+def make_tistos_hlt1(data):
+    return HltTisTos(
+        selection_type="Hlt1",
+        trigger_lines=[
+            f"{x}Decision" for x in Hlt1_decisions if "GECPassthrough" not in x
+        ],
+        data=data,
+    )
+
+
+
+def make_comp_variables(data):
+    v2_pvs = get_pvs()
+
+    variables = FunctorCollection({
+        "Q": F.CHARGE,
+        "MAX_PT": F.MAX(F.PT),
+        "MIN_PT": F.MIN(F.PT),
+        "SUM_PT": F.SUM(F.PT),
+        "MAX_P": F.MAX(F.P),
+        "MIN_P": F.MIN(F.P),
+        "BPVDIRA": F.BPVDIRA(v2_pvs),
+        "CHI2VXNDOF": F.CHI2DOF,
+        "BPVFDCHI2": F.BPVFDCHI2(v2_pvs),
+        "BPVFD": F.BPVFD(v2_pvs),
+        "BPVVDRHO": F.BPVVDRHO(v2_pvs),
+        "BPVVDZ": F.BPVVDZ(v2_pvs),
+        "BPVIPCHI2": F.BPVIPCHI2(v2_pvs),
+        "BPVIP": F.BPVIP(v2_pvs),
+        "BPVLTIME": F.BPVLTIME(v2_pvs),
+        "MAX_BPVIPCHI2": F.MAX(F.BPVIPCHI2(v2_pvs)),
+        "MIN_BPVIPCHI2": F.MIN(F.BPVIPCHI2(v2_pvs)),
+        "MAXDOCACHI2": F.MAXDOCACHI2,
+        "MAXDOCA": F.MAXDOCA,
+        "MAXSDOCACHI2": F.MAXSDOCACHI2,
+        "MAXSDOCA": F.MAXSDOCA,
+        "ETA": F.ETA,
+        "PHI": F.PHI,
+        "END_VX": F.END_VX,
+        "END_VY": F.END_VY,
+        "END_VZ": F.END_VZ,
+        "BPVX": F.BPVX(v2_pvs),
+        "BPVY": F.BPVY(v2_pvs),
+        "BPVZ": F.BPVZ(v2_pvs),
+        "DOCA12": F.DOCA(Child1=1, Child2=2)
+    })
+
+    variables += Kinematics()
+    variables += ParticleID(extra_info=True)
+    variables += make_tistos_hlt1(data)
+    # variables += make_tistos_hlt2(data) -- NOT IMPLEMENTED YET
+
+    return variables
+
+
+def make_basic_variables(data):
+    v2_pvs = get_pvs()
+
+    variables = FunctorCollection({
+        "Q":
+        F.CHARGE,
+        "TRCHI2DOF":
+        F.CHI2DOF,
+        "TRGHOSTPROB":
+        F.GHOSTPROB,
+        "BPVIPCHI2":
+        F.BPVIPCHI2(v2_pvs),
+        "PIDK":
+        F.PID_K,
+        "PIDe":
+        F.PID_E,
+        "PIDmu":
+        F.PID_MU,
+        "PIDp":
+        F.PID_P,
+        "ISMUON":
+        F.ISMUON,
+        "BPVIP":
+        F.BPVIP(v2_pvs),
+        "ETA":
+        F.ETA,
+        "PHI":
+        F.PHI,
+        # some track-related variables
+        "NDOF":
+        F.VALUE_OR(-1) @ F.NDOF @ F.TRACK,
+        "NFTHITS":
+        F.VALUE_OR(-1) @ F.NFTHITS @ F.TRACK,
+        "NHITS":
+        F.VALUE_OR(-1) @ F.NHITS @ F.TRACK,
+        "NUTHITS":
+        F.VALUE_OR(-1) @ F.NUTHITS @ F.TRACK,
+        "NVPHITS":
+        F.VALUE_OR(-1) @ F.NVPHITS @ F.TRACK,
+        "TRACKHASVELO":
+        F.VALUE_OR(-1) @ F.TRACKHASVELO @ F.TRACK,
+        "TRACKHISTORY":
+        F.VALUE_OR(-1) @ F.TRACKHISTORY @ F.TRACK,
+        "TRACKPT":
+        F.TRACK_PT,
+    })
+
+    variables += Kinematics()
+    variables += ParticleID(extra_info=True)
+    variables += make_tistos_hlt1(data)
+
+    return variables
+
+
+def make_mc_variables(data):
+    mctruth = MCTruthAndBkgCat(data)
+    # add helper lambda that configures a functor to get truth information
+    MCTRUTH = lambda func: F.MAP_INPUT(Functor=func, Relations=mctruth.MCAssocTable)
+    trueid_bkgcat_info = FunctorCollection({
+        "TRUEID": F.VALUE_OR(0) @ MCTRUTH(F.PARTICLE_ID),
+        "TRUEKEY": F.VALUE_OR(-1) @ MCTRUTH(F.OBJECT_KEY),
+        "TRUEPT": MCTRUTH(F.PT),
+        "TRUEPX": MCTRUTH(F.PX),
+        "TRUEPY": MCTRUTH(F.PY),
+        "TRUEPZ": MCTRUTH(F.PZ),
+        "TRUEENERGY": MCTRUTH(F.ENERGY),
+        "TRUEP": MCTRUTH(F.P),
+        "TRUEFOURMOMENTUM": MCTRUTH(F.FOURMOMENTUM),
+        "BKGCAT": F.BKGCAT(Relations=mctruth.BkgCatTable),
+    })
+    return trueid_bkgcat_info
+
+
+def make_hlt2_event_variables(line_name, line_data):
+    v2_pvs = get_pvs()
+
+    evt_variables = EventInfo()
+    # evt_variables += SelectionInfo(
+    #    selection_type="Hlt2", trigger_lines=Hlt2_decisions)
+    evt_variables += FunctorCollection({
+        "NPV": F.SIZE(v2_pvs),
+        "ALLPVX[NPVs]": F.ALLPVX(v2_pvs),
+        "ALLPVY[NPVs]": F.ALLPVY(v2_pvs),
+        "ALLPVZ[NPVs]": F.ALLPVZ(v2_pvs),
+    })
+    return evt_variables
+
+
+def DecayTreeFitter(data,
+                    pv_only_fit=True,
+                    mass_constraints=[],
+                    prefix="DTF",
+                    suffix=""):
+    from DecayTreeFitter import DecayTreeFitter
+
+    pvs = get_pvs()
+
+    variables_origin = FunctorCollection()
+    variables_composite = FunctorCollection()
+    variables_basic = FunctorCollection()
+
+    if pv_only_fit:
+        DTF = DecayTreeFitter(
+            name="PV_Fit_{hash}",
+            input_particles=data,
+            input_pvs=pvs,
+        )
+
+        variables_origin += DecayTreeFitterResults(
+            DTF=DTF,
+            prefix=prefix + "_PV" + suffix,
+            decay_origin=True,
+            with_lifetime=True,
+            with_kinematics=True,
+        )
+
+        variables_composite += DecayTreeFitterResults(
+            DTF=DTF,
+            prefix=prefix + "_PV" + suffix,
+            decay_origin=False,
+            with_lifetime=True,
+            with_kinematics=True,
+        ) + FunctorCollection({
+            f"{prefix}_PV{suffix}_MASS": DTF.MASS,
+            f"{prefix}_PV{suffix}_MASSERR": DTF.MASSERR
+        })
+
+        variables_basic += DecayTreeFitterResults(
+            DTF=DTF,
+            prefix=prefix + "_PV" + suffix,
+            decay_origin=False,
+            with_lifetime=False,
+            with_kinematics=True,
+        )
+
+    if len(mass_constraints) > 0:
+        DTF_M = DecayTreeFitter(
+            name="PVandMass_Fit_{hash}",
+            input_particles=data,
+            input_pvs=pvs,
+            mass_constraints=mass_constraints,
+        )
+
+        variables_origin += DecayTreeFitterResults(
+            DTF=DTF_M,
+            prefix=prefix + "_PVandMass" + suffix,
+            decay_origin=True,
+            with_lifetime=True,
+            with_kinematics=True,
+        )
+
+        variables_composite += DecayTreeFitterResults(
+            DTF=DTF_M,
+            prefix=prefix + "_PVandMass" + suffix,
+            decay_origin=False,
+            with_lifetime=True,
+            with_kinematics=True,
+        ) + FunctorCollection(
+            {
+                f"{prefix}_PVandMass{suffix}_MASS": DTF_M.MASS,
+                f"{prefix}_PVandMass{suffix}_MASSERR": DTF_M.MASSERR
+            })
+
+        variables_basic += DecayTreeFitterResults(
+            DTF=DTF_M,
+            prefix=prefix + "_PVandMass" + suffix,
+            decay_origin=False,
+            with_lifetime=False,
+            with_kinematics=True,
+        )
+
+    return variables_origin, variables_composite, variables_basic
\ No newline at end of file
diff --git a/phiphi/decay_factory.py b/phiphi/decay_factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b9a5f0c8dd9c6300e00451806d8e61c1043f181
--- /dev/null
+++ b/phiphi/decay_factory.py
@@ -0,0 +1,237 @@
+from string import Template
+from typing import Any
+import re
+
+
+def _dict_append(user_dict: dict, user_key: Any, user_value: Any) -> dict:
+    """
+    Small utility function to either instantiate or add value(s) to a dictionary key
+    """
+    if user_key not in user_dict:
+        user_dict[user_key] = user_value
+    else:
+        user_dict[user_key] += user_value
+    return user_dict
+
+
+class DecayFactory:
+    """
+    Use to recover the Run 1/2 DaVinci functionality of templated decay descriptors.
+    E.g. instead of defining each branch and decay descriptor separately, do
+    something like:
+    >>> from .decay_factory import DecayFactory
+    >>> decay_desc = "${B}[B_s0 -> ${Kst}(K*(892)0 -> ${Kp}K+ ${pim}pi-) ${Kstb}(K*(892)~0 -> ${Km}K- ${pip}pi+)]CC"
+    >>> df = DecayFactory(decay_desc)
+    >>> fields = df.fields
+    >>> print(fields)
+    {"B": "[B_s0 -> (K*(892)0 -> K+ pi-) (K*(892)~0 -> K- pi+)]CC",
+    "Kst": "[B_s0 -> ^(K*(892)0 -> K+ pi-) (K*(892)~0 -> K- pi+)]CC",
+    "Kp": "[B_s0 -> (K*(892)0 -> ^K+ pi-) (K*(892)~0 -> K- pi+)]CC",
+    "pim": "[B_s0 -> (K*(892)0 -> K+ ^pi-) (K*(892)~0 -> K- pi+)]CC",
+    "Kstb": "[B_s0 -> (K*(892)0 -> K+ pi-) ^(K*(892)~0 -> K- pi+)]CC",
+    "Km": "[B_s0 -> (K*(892)0 -> K+ pi-) (K*(892)~0 -> ^K- pi+)]CC",
+    "pip": "[B_s0 -> (K*(892)0 -> K+ pi-) (K*(892)~0 -> K- ^pi+)]CC"}
+
+    Some additional functionality is added to try and automatically figure out which
+    paritlces are the `origin` (parent), `composite` particles (like `Kst` above) and
+    `basic` particles i.e. stable ones like `Kp`.
+    This is because often we have certain functor collections that we want include for
+    just the parent particle or intermediate resonances or stable particles etc.
+    Continuing from the above example:
+    >>> print(df.origin)
+    B
+    >>> print(df.composites)
+    ["Kst", "Kstb"]
+    >>> print(df.basics)
+    ["Kp", "pim", "Km", "pip"]
+
+    You can then also get the `variables` dict used in FunTuple with the
+    `add_variables` method. This takes optional arguments so that you can add functor
+    collections to any or all of the branches.
+    E.g.
+    >>> from FunTuple import functorcollections as FC
+    >>> from FunTuple import FunctorCollection
+    >>> from PyConf.reading import get_pvs
+    >>> kinematics = FC.Kinematics()
+    >>> pvs = get_pvs()
+    >>> ltime = FunctorCollection({"BPVLTIME": F.BPVLTIME(pvs)})
+
+    >>> df.add_variables(
+            origin_vars=kinematics+ltime,
+            composite_vars=kinematics,
+            basic_vars=kinematics
+        )
+    >>> variables = df.variables
+
+    The `df.add_variables(...)` is equivalent to:
+    >>> variables = {
+        "B": kinematics + ltime,
+        "Kst": kinematics,
+        "Kstb": kinematics,
+        "Kp": kinematics,
+        "Km": kinematics,
+        "pip": kinematics,
+        "pim": kinematics,
+    }
+
+    You can also use `add_variables` to add variables only to specific composite or
+    basic branches rather than all of them, using the `composite_branches` and/or
+    `basic_branches` arguments, e.g. as above:
+    >>> df.add_variables(
+            origin_vars=kinematics+ltime,
+            composite_vars=kinematics,
+            basic_vars=kinematics,
+            basic_branches=["Kp", "pim"]
+        )
+
+    will add the `kinematics` FunctorCollection only to the `Kp` and `pim` branches.
+    You can call `add_variables` multiple times to keep adding variables to the
+    `variables` dictionary.
+    """
+
+    def __init__(self, decay_desc: str) -> None:
+        self.decay_desc = decay_desc
+        # self.particle_map = self.get_particles()
+        self.fields, self.particles = self._get_fields()
+        self.origin, self.composites, self.basics = self._get_groups()
+        self.variables = {}
+
+    # Copy (almost) verbatim from Run 1/2 DaVinci:
+    # https://gitlab.cern.ch/lhcb/Analysis/-/blob/run2-patches/Phys/DecayTreeTuple/python/DecayTreeTuple/Configuration.py#L199
+    def _get_fields(self) -> tuple[dict, tuple[str]]:
+        template = self.decay_desc
+        # The argument 'template' is a Python string template
+        # e.g. "[${D}D0 -> ${kaon}K- ${pion}pi+]CC"
+        # Here ["D", "kaon", "pion"] are the branch names you want
+        dd = Template(template)
+
+        # This parses the temlate to get the list of branch names,
+        # i.e. ["D", "kaon", "pion"]
+        particles = [
+            y[1] if len(y[1]) else y[2]
+            for y in dd.pattern.findall(dd.template) if len(y[1]) or len(y[2])
+        ]
+
+        # To form the decay descriptor, we need to mark all the particles
+        # except for the top-level particle
+        mapping = {
+            p: '^' if particles.index(p) != 0 else ''
+            for p in particles
+        }
+        clean = dd.template.replace(' ', '')
+        for i, o in enumerate(re.findall("(\[\$|\$)", clean)):
+            if o == '[$':
+                mapping[particles[i]] = ''
+
+        # Now make the branches
+        branches = {}
+        for p in particles:
+            # Need a version of the descriptor where particle 'p' is marked but nothing
+            # else is.
+            # Use mapping to ensure the parent particle is never marked.
+            branches[p] = dd.substitute(
+                {q: mapping[p] if p == q else ''
+                 for q in particles})
+
+        return branches, particles
+
+    def _get_groups(self) -> tuple[str, tuple[str], tuple[str]]:
+        template = self.decay_desc
+        dd = Template(template)
+
+        particles = []
+        origin = ''
+        composites = []
+        basics = []
+        for i, match in enumerate(dd.pattern.finditer(dd.template)):
+            particle = [
+                y[1] if len(y[1]) else y[2]
+                for y in dd.pattern.findall(match.group())
+                if len(y[1]) or len(y[2])
+            ][0]
+            particles.append(particle)
+            if i == 0:
+                origin = particle
+            else:
+                next_char = dd.template[match.end()]
+                if next_char == '(':
+                    composites.append(particle)
+                else:
+                    basics.append(particle)
+        return origin, composites, basics
+
+    def add_variables(self,
+                      origin_vars: list = None,
+                      composite_vars: list = None,
+                      basic_vars: list = None,
+                      composite_branches: list = None,
+                      basic_branches: list = None) -> None:
+        """
+        You can get the `variables` dict used in FunTuple with the `add_variables`
+        method. This takes optional arguments so that you can add functor collections
+        to any or all of the branches.
+        E.g.
+        >>> from FunTuple import functorcollections as FC
+        >>> from FunTuple import FunctorCollection
+        >>> from PyConf.reading import get_pvs
+        >>> kinematics = FC.Kinematics()
+        >>> pvs = get_pvs()
+        >>> ltime = FunctorCollection({"BPVLTIME": F.BPVLTIME(pvs)})
+
+        >>> df.add_variables(
+                origin_vars=kinematics+ltime,
+                composite_vars=kinematics,
+                basic_vars=kinematics
+            )
+        >>> variables = df.variables
+
+        The `df.add_variables(...)` is equivalent to:
+        >>> variables = {
+            "B": kinematics + ltime,
+            "Kst": kinematics,
+            "Kstb": kinematics,
+            "Kp": kinematics,
+            "Km": kinematics,
+            "pip": kinematics,
+            "pim": kinematics,
+        }
+
+        You can also use `add_variables` to add variables only to specific composite or
+        basic branches rather than all of them, using the `composite_branches` and/or
+        `basic_branches` arguments, e.g. as above:
+        >>> df.add_variables(
+                origin_vars=kinematics+ltime,
+                composite_vars=kinematics,
+                basic_vars=kinematics,
+                basic_branches=["Kp", "pim"]
+            )
+
+        will add the `kinematics` FunctorCollection only to the `Kp` and `pim` branches.
+        You can call `add_variables` multiple times to keep adding variables to the
+        `variables` dictionary.
+        """
+        if origin_vars is not None:
+            self.variables = _dict_append(self.variables, self.origin,
+                                          origin_vars)
+        if composite_vars is not None:
+            to_add = composite_branches or self.composites
+            for composite in to_add:
+                if composite not in self.composites:
+                    print(
+                        f"Unexpected composite branch {composite} not in list of \
+                        composites: {self.composites}"
+                    )
+                    raise RuntimeError
+                self.variables = _dict_append(self.variables, composite,
+                                              composite_vars)
+        if basic_vars is not None:
+            to_add = basic_branches or self.basics
+            for basic in to_add:
+                if basic not in self.basics:
+                    print(
+                        f"Unexpected basic branch {basic} not in list of \
+                        basics: {self.basics}"
+                    )
+                    raise RuntimeError
+                self.variables = _dict_append(self.variables, basic,
+                                              basic_vars)
\ No newline at end of file
diff --git a/phiphi/info.yaml b/phiphi/info.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..50e1c5a762b8b7c55b064d1447bce473207ce731
--- /dev/null
+++ b/phiphi/info.yaml
@@ -0,0 +1,45 @@
+defaults:
+  application: "DaVinci/v64r8@x86_64_v2-el9-clang16+detdesc-opt"
+  wg: BnoC
+  automatically_configure: no
+  inform:
+    - art.s@cern.ch
+  output: Tuple.ROOT
+
+
+Lb2PhipK_W31_34_MagUp_MC_Hlt2BnoC_BdsToPhiPhi:
+  input: 
+    bk_query: "/MC/2024/Beam6800GeV-2024.W31.34-MagUp-Nu6.3-25ns-Pythia8/Sim10d/HLT1_2024.W31.34_noUT/HLT2-2024.W31.34/15104020/HLT2.DST"
+    sample_fraction: 0.1
+    sample_seed: HelloWorld
+  options:
+    entrypoint: phiphi.tuple_maker:make_tuples
+    extra_options:
+      input_type: ROOT
+      simulation: True
+      data_type: "Upgrade"
+      conddb_tag: "sim10-2024.Q3.4-v1.3-mu100"
+      dddb_tag: "dddb-20240427"
+      input_stream: bnoc
+      input_process: "Hlt2"
+    extra_args:
+      - "15104020"
+
+
+Bd2PhiKst_W31_34_MagUp_MC_Hlt2BnoC_BdsToPhiPhi:
+  input: 
+    bk_query: "/MC/2024/Beam6800GeV-2024.W31.34-MagUp-Nu6.3-25ns-Pythia8/Sim10d/HLT1_2024.W31.34_noUT/HLT2-2024.W31.34/13104021/HLT2.DST"
+    sample_fraction: 0.1
+    sample_seed: HelloWorld
+  options:
+    entrypoint: phiphi.tuple_maker:make_tuples
+    extra_options:
+      input_type: ROOT
+      simulation: True
+      data_type: "Upgrade"
+      conddb_tag: "sim10-2024.Q3.4-v1.3-mu100"
+      dddb_tag: "dddb-20240427"
+      input_stream: bnoc
+      input_process: "Hlt2"
+    extra_args:
+      - "13104021"
\ No newline at end of file
diff --git a/phiphi/options.py b/phiphi/options.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/phiphi/tuple_maker.py b/phiphi/tuple_maker.py
new file mode 100644
index 0000000000000000000000000000000000000000..2524c8e979cc09a466979435c272c9edabe8fab0
--- /dev/null
+++ b/phiphi/tuple_maker.py
@@ -0,0 +1,317 @@
+###############################################################################
+# (c) Copyright 2023 CERN for the benefit of the LHCb Collaboration           #
+#                                                                             #
+# This software is distributed under the terms of the GNU General Public      #
+# Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING".   #
+#                                                                             #
+# In applying this licence, CERN does not waive the privileges and immunities #
+# granted to it by virtue of its status as an Intergovernmental Organization  #
+# or submit itself to any jurisdiction.                                       #
+###############################################################################
+
+from FunTuple import FunTuple_Particles as FunTuple
+from DaVinci import Options, make_config
+from DaVinci.algorithms import create_lines_filter
+from PyConf.reading import get_particles
+
+from .bnoc_lines import event_type_to_hlt2_lines
+from .bnoc_utils import (
+    make_require_pvs,
+    vv_lines,
+    hhhh_lines,
+    make_res,
+    make_basic_variables,
+    make_comp_variables,
+    make_hlt2_event_variables,
+    make_mc_variables,
+)
+from .bnoc_utils import DecayTreeFitter as DecayTreeFitterBnOC 
+from DaVinciMCTools import MCTruthAndBkgCat
+from DecayTreeFitter import DecayTreeFitter
+from PyConf import configurable
+from .decay_factory import DecayFactory
+from Hlt2Conf.standard_particles import make_long_kaons, make_long_pions,make_phi2kk
+###################################################
+from GaudiKernel.SystemOfUnits import MeV, mm, GeV, picosecond
+from DecayTreeFitter import DecayTreeFitter
+from PyConf import configurable
+
+import Functors as F
+from FunTuple import FunTuple_Particles as FunTuple
+from DaVinci import Options, make_config
+from DaVinci.algorithms import create_lines_filter
+from PyConf.reading import get_particles, get_pvs
+import Functors.math as fmath
+from DaVinci import make_config, Options
+from DaVinci.algorithms import create_lines_filter
+from FunTuple import FunctorCollection
+from FunTuple import FunTuple_Particles as Funtuple
+from Hlt2Conf.standard_particles import make_long_kaons,make_has_rich_long_kaons, make_long_pions,make_phi2kk
+from RecoConf.reconstruction_objects import make_pvs
+from PyConf.Algorithms import ParticleRangeFilter, TwoBodyCombiner, ThreeBodyCombiner
+from Hlt2Conf.algorithms_thor import ParticleCombiner
+from Functors.math import in_range
+from GaudiKernel.SystemOfUnits import MeV
+
+from .bnoc_utils import make_basic_variables, make_comp_variables, make_hlt2_event_variables#, DecayTreeFitter
+from Hlt2Conf.lines.bnoc.builders.basic_builder import make_KS_LL, make_wide_kstar0, make_phi, make_phi_fake,  make_rho0, make_rho0_fake, make_omega0, make_omega0_fake, make_tight_kaons, make_tight_pions, make_soft_kaons, make_merged_pi0s, make_soft_pions
+from Hlt2Conf.lines.bnoc.builders.b_builder import make_btovv, make_b2Kpi, make_BdsToVKpKm
+from Hlt2Conf.lines.bnoc.utils import check_process
+from PyConf.Algorithms import (
+    FunctionalParticleMaker, LHCb__Phys__ParticleMakers__PhotonMaker as
+    PhotonMaker, LHCb__Phys__ParticleMakers__MergedPi0Maker as MergedPi0Maker,
+    Proto2ChargedBasic, ParticleWithBremMaker, FunctionalDiElectronMaker as
+    FunctionalDiElectronMakerAlg)
+from PyConf import configurable
+from RecoConf.reconstruction_objects import (
+    make_pvs as _make_pvs, make_charged_protoparticles as
+    _make_charged_protoparticles, make_neutral_protoparticles as
+    _make_neutral_protoparticles)
+
+from RecoConf.ttrack_selections_reco import make_ttrack_protoparticles, make_ttrack_MVAfiltered_protoparticles
+
+from Hlt2Conf.algorithms_thor import ParticleCombiner, ParticleFilter
+import Functors as F
+from Functors.math import in_range
+
+from RecoConf.core_algorithms import make_unique_id_generator
+
+###################################################
+import FunTuple.functorcollections as FC
+#from Hlt2Conf.flavourTagging import run2_all_taggers
+#from PyConf.Algorithms import Run2OSVertexChargeTagger
+#from Hlt2Conf.flavourTagging import make_run2_oppositeside_vertex_charge_tagging_particles
+
+def BdsTohhhh_tuple(lines, simulation=False):
+    algs = {}
+
+    require_pvs = make_require_pvs()
+
+    for name, line_info in lines.items():
+        decay_desc = line_info["desc"]
+        combinations = line_info["combinations"]
+        turbo_line = f"Hlt2BnoC_{name}"
+        input_data = get_particles(f"/Event/HLT2/{turbo_line}/Particles")
+        df = DecayFactory(decay_desc)
+        dtf_vars_origin, dtf_vars_composites, dtf_vars_basic = DecayTreeFitterBnOC(
+            input_data, prefix='DTF')
+        df.add_variables(
+            origin_vars=make_comp_variables(input_data) + dtf_vars_origin,
+            basic_vars=make_basic_variables(input_data) + dtf_vars_basic)
+        if simulation:
+            df.add_variables(
+                origin_vars=make_mc_variables(input_data),
+                composite_vars=make_mc_variables(input_data),
+                basic_vars=make_mc_variables(input_data))
+
+        for combination in combinations:
+            particles = [df.particles[i] for i in combination]
+            df.add_variables(origin_vars=make_res(combination, particles))
+
+        evt_vars = make_hlt2_event_variables(turbo_line, line_data=input_data)
+        my_filter = create_lines_filter(
+            name=f"HDRFilter_{turbo_line}", lines=[f"{turbo_line}"])
+
+        my_tuple = FunTuple(
+            name=name,
+            tuple_name="DecayTree",
+            inputs=input_data,
+            fields=df.fields,
+            variables=df.variables,
+            event_variables=evt_vars,
+            store_multiple_cand_info=True,
+        )
+        algs[name] = [my_filter, require_pvs, my_tuple]
+    return algs
+
+
+def BdsToVV_tuple(lines, simulation=False):
+    from Hlt2Conf.flavourTagging import run2_all_taggers
+    user_algorithms = {}
+    require_pvs = make_require_pvs()
+
+    for name, decay_info in lines.items():
+        decay_desc = decay_info["desc"]
+        mass_constraints = decay_info["mass_constraints"]
+        turbo_line = f"Hlt2BnoC_{name}"
+        input_data = get_particles(f"/Event/HLT2/{turbo_line}/Particles")
+        evt_vars = make_hlt2_event_variables(turbo_line, line_data=input_data)
+        df = DecayFactory(decay_desc)
+        dtf_pv_origin, dtf_pv_composite, dtf_pv_basic = DecayTreeFitterBnOC(
+            input_data, prefix='DTF', pv_only_fit=True)
+        all_tagging = run2_all_taggers(input_data)
+        ##only add tagging for B
+        kinematics = FC.Kinematics()
+#        df.add_variables(
+#            origin_vars=kinematics,#+FC.FlavourTaggingResults(all_tagging),
+#            composite_vars=kinematics,
+#            basic_vars=kinematics
+#        )
+        df.add_variables(
+            origin_vars=make_comp_variables(input_data) + dtf_pv_origin+FC.FlavourTaggingResults(all_tagging),
+            composite_vars=make_comp_variables(input_data) + dtf_pv_composite,
+            basic_vars=make_basic_variables(input_data) + dtf_pv_basic)
+
+        if simulation:
+            df.add_variables(
+                origin_vars=make_mc_variables(input_data),
+                composite_vars=make_mc_variables(input_data),
+                basic_vars=make_mc_variables(input_data))
+
+        for mass_constraint in mass_constraints:
+            suffix, constraints = mass_constraint
+            dtf_mass_origin, dtf_mass_composite, dtf_mass_basic = DecayTreeFitterBnOC(
+                input_data,
+                prefix='DTF',
+                pv_only_fit=False,
+                mass_constraints=constraints,
+                suffix=suffix)
+            df.add_variables(
+                origin_vars=dtf_mass_origin,
+                composite_vars=dtf_mass_composite,
+                basic_vars=dtf_mass_basic)
+        my_filter = create_lines_filter(
+            name=f"HDRFilter_{turbo_line}", lines=[f"{turbo_line}"])
+
+        my_tuple = FunTuple(
+            name=f"{name}_Tuple",
+            tuple_name="DecayTree",
+            inputs=input_data,
+            fields=df.fields,
+            variables=df.variables,
+            event_variables=evt_vars,
+        )
+        user_algorithms[name] = [my_filter, require_pvs, my_tuple]
+    return user_algorithms
+
+
+#def make_promptphiphi_tuple(process):
+def test_alg(options: Options):
+    phi = make_phi2kk()
+    kaon = make_long_kaons()
+    pvs = make_pvs()
+    descriptor = "B0 -> phi(1020) K+ K-"
+#    combination_code = in_range(5000 * MeV, F.MASS, 6000 * MeV)
+    combination_code = F.require_all(
+           in_range(4000 * MeV, F.MASS, 6000 * MeV),
+           (F.PT) > 1.8*GeV )
+
+    vertex_code = F.CHI2DOF < 10
+
+    p = ThreeBodyCombiner(
+        name="TightKst2Kpi",
+        Input1=phi,
+        Input2=kaon,
+        Input3=kaon,
+        DecayDescriptor=descriptor,
+        CombinationCut=combination_code,
+        ParticleCombiner="ParticleVertexFitter",
+        CompositeCut=vertex_code,
+        PrimaryVertices=pvs,
+        )
+    v2_pvs = get_pvs()
+#    all_vars = FunctorCollection({})
+    all_vars = FC.Kinematics()  
+    DTF = DecayTreeFitter(
+        name="DTF",
+        input_particles=p.OutputParticles,
+        mass_constraints=["phi(1020)"],
+        constrain_to_ownpv=True,
+    )
+    all_vars+=FunctorCollection(
+    {
+            "DTF_PT": DTF(F.PT),
+            "DTF_MASS": DTF(F.MASS),
+            "DTF_CHI2": DTF(F.CHI2),
+            "DTF_NDOF": DTF.NDOF,
+            "DTF_CHI2DOF": DTF.CHI2DOF,
+            "DTF_MASS": DTF.MASS,
+            "DTF_MASSERR": DTF.MASSERR,
+            "DTF_P": DTF.P,
+            "DTF_PERR": DTF.PERR,
+            "DTF_CTAU": DTF.CTAU,
+            "DTF_CTAUERR": DTF.CTAUERR,
+            "DTF_FD": DTF.FD,
+            "DTF_FDERR": DTF.FDERR,
+            "BPVDLS": F.BPVDLS(v2_pvs),
+            "BPVIPCHI2": F.BPVIPCHI2(v2_pvs),
+            "BPVCORRM": F.BPVCORRM(v2_pvs),
+            "BPVCORRMERR": F.BPVCORRMERR(v2_pvs),
+            "BPVIP": F.BPVIP(v2_pvs),
+            "BPVIPCHI2": F.BPVIPCHI2(v2_pvs),
+            "BPVLTIME": F.BPVLTIME(v2_pvs),
+            "MAX_BPVIPCHI2": F.MAX(F.BPVIPCHI2(v2_pvs)),
+            "MIN_BPVIPCHI2": F.MIN(F.BPVIPCHI2(v2_pvs)),
+            "MAXDOCACHI2": F.MAXDOCACHI2,
+            "MAXDOCA": F.MAXDOCA,
+            "MAXSDOCACHI2": F.MAXSDOCACHI2,
+            "MAXSDOCA": F.MAXSDOCA,
+            # Important note: specify an invalid value for integer functors if there exists no truth info.
+            #                 The invalid value for floating point functors is set to nan.
+        }
+    )
+    variables = FC.Kinematics()
+
+    kst_fields = {"B0": "B0 -> phi(1020) K+ K-", "phi": "B0 -> ^phi(1020) K+ K-"}
+    kst_vars = {"B0":all_vars, "phi": variables}
+    MCTRUTH = MCTruthAndBkgCat(p.OutputParticles, name="MCTruthAndBkgCat_spruce")
+    trueid_bkgcat_info = {
+        # Important note: specify an invalid value for integer functors if there exists no truth info.
+        #                 The invalid value for floating point functors is set to nan.
+        "TRUEID": F.VALUE_OR(0) @ MCTRUTH(F.PARTICLE_ID),
+        "TRUEKEY": F.VALUE_OR(-1) @ MCTRUTH(F.OBJECT_KEY),
+        "TRUEPT": MCTRUTH(F.PT),
+        "TRUEPX": MCTRUTH(F.PX),
+        "TRUEPY": MCTRUTH(F.PY),
+        "TRUEPZ": MCTRUTH(F.PZ),
+        "TRUEENERGY": MCTRUTH(F.ENERGY),
+        "TRUEP": MCTRUTH(F.P),
+        "TRUEFOURMOMENTUM": MCTRUTH(F.FOURMOMENTUM),
+        "BKGCAT": MCTRUTH.BkgCat,
+        "TRUE_LIFETIME": MCTRUTH(F.MC_LIFETIME)
+    }
+    for field in kst_vars.keys():
+        kst_vars[field] += FunctorCollection(trueid_bkgcat_info)
+
+    evt_vars = make_hlt2_event_variables(p, p.OutputParticles)
+    ntuple = Funtuple(
+        name="TupleKst",
+        tuple_name="DecayTree",
+        fields=kst_fields,
+        variables=kst_vars,
+        event_variables=evt_vars,
+        inputs=p.OutputParticles,
+    )
+    algs={"B2PhiKK": [ntuple]}
+    return make_config(options, algs)
+
+def BdsTohhhh_alg(options: Options):
+    all_user_algorithms = {}
+    all_user_algorithms.update(BdsTohhhh_tuple(simulation=options.simulation))
+
+    return make_config(options, all_user_algorithms)
+
+
+def BdsToVV_alg(options: Options):
+    all_user_algorithms = {}
+    all_user_algorithms.update(BdsToVV_tuple(simulation=options.simulation))
+
+    return make_config(options, all_user_algorithms)
+
+#def test_alg(options: Options):
+#    all_user_algorithms = {}
+#    all_user_algorithms.update(make_promptphiphi_tuple())
+#    return make_config(options, all_user_algorithms)
+
+
+def make_tuples(options: Options, event_type=None):
+    all_user_algorithms = {}
+#    all_user_algorithms.update(make_promptphiphi_tuple(simulation=options.simulation)  )
+    hlt2_lines = event_type_to_hlt2_lines[event_type]
+    vv_to_run = {k: v for k, v in vv_lines.items() if f'Hlt2BnoC_{k}' in hlt2_lines}
+    hhhh_to_run = {k: v for k, v in hhhh_lines.items() if f'Hlt2BnoC_{k}' in hlt2_lines}
+    if len(vv_to_run) > 0:
+        all_user_algorithms.update(BdsToVV_tuple(vv_to_run, simulation=options.simulation))
+    if len(hhhh_to_run) > 0:
+        all_user_algorithms.update(BdsTohhhh_tuple(hhhh_to_run, simulation=options.simulation))
+    return make_config(options, all_user_algorithms)
\ No newline at end of file