def filter_by_substance_composition(
    data_set, compositions_to_include, compositions_to_exclude
):
    """Filters the data set so that it only contains properties measured for substances
    of specified compositions.

    This method is similar to `filter_by_smiles`, however here we explicitly define
    the full substances compositions, rather than individual smiles which should
    either be included or excluded.

    Examples
    --------
    To filter the data set to only include measurements for pure methanol, pure benzene
    or a aqueous ethanol mix:

    >>> filter_by_substance_composition(
    >>>     data_set,
    >>>     compositions_to_include=[
    >>>         ('CO',),
    >>>         ('C1=CC=CC=C1',),
    >>>         ('CCO', 'O')
    >>>     ]
    >>> )

    To excludes measurements made for an aqueous mix of benzene:

    >>> filter_by_substance_composition(
    >>>     data_set, compositions_to_exclude=[('O', 'C1=CC=CC=C1')]
    >>> )

    Parameters
    ----------
    data_set: pandas.DataFrame
        The data set to filter
    compositions_to_include: list of tuple of str, optional
        The substances compositions to retain, where each tuple in the list contains
        the smiles patterns which make up the substance to include. This option is mutually
        exclusive with `compositions_to_exclude`
    compositions_to_exclude: list of tuple str, optional
        The smiles patterns to exclude, where each tuple in the list contains
        the smiles patterns which make up the substance to enclude. This option is mutually
        exclusive with `compositions_to_include`

    Returns
    -------
    pandas.DataFrame
        The filtered data set.
    """

    if (compositions_to_include is None and compositions_to_exclude is None) or (
        compositions_to_include is not None and compositions_to_exclude is not None
    ):

        raise ValueError(
            "Either a list of compositions to include, or a list of compositions "
            "to exclude must be provided, but not both."
        )

    # Make sure the provided smiles tuple lists are in an expected format.
    validated_compositions_to_include = []
    validated_compositions_to_exclude = []

    for smiles_tuple in (
        [] if compositions_to_include is None else compositions_to_include
    ):

        if not isinstance(smiles_tuple, (str, tuple)):

            raise ValueError(
                "The `compositions_to_include` argument must either be a list of strings, "
                "or a list of string tuples"
            )

        smiles_list = (
            [smiles_tuple] if isinstance(smiles_tuple, str) else [*smiles_tuple]
        )
        validated_compositions_to_include.append(tuple(sorted(smiles_list)))

    for smiles_tuple in (
        [] if compositions_to_exclude is None else compositions_to_exclude
    ):

        if not isinstance(smiles_tuple, (str, tuple)):
            raise ValueError(
                "The `compositions_to_exclude` argument must either be a list of strings, "
                "or a list of string tuples"
            )

        smiles_list = (
            [smiles_tuple] if isinstance(smiles_tuple, str) else [*smiles_tuple]
        )
        validated_compositions_to_exclude.append(tuple(sorted(smiles_list)))

    def filter_function(data_row):

        n_components = data_row["N Components"]

        composition_tuple = tuple(
            sorted(
                [data_row[f"Component {index + 1}"] for index in range(n_components)]
            )
        )

        return (
            compositions_to_exclude is not None
            and composition_tuple not in validated_compositions_to_exclude
        ) or (
            compositions_to_include is not None
            and composition_tuple in validated_compositions_to_include
        )

    return data_set[data_set.apply(filter_function, axis=1)]