ms3.bs4_measures — ms3 0.1.2.post0.dev2+g9d887e1.dirty documentation

# Source code for ms3.bs4_measures

    import pandas as pd
    import numpy as np
    from fractions import Fraction as frac

    from .logger import get_logger, function_logger

    class MeasureList:
        """ Turns a _MSCX_bs4._measures DataFrame into a measure list and performs a couple of consistency checks on the score.

        Attributes
        ----------
        df : :obj:`pandas.DataFrame`
            The input DataFrame from _MSCX_bs4.raw_measures
        section_breaks : :obj:`bool`, default True
            By default, section breaks allow for several anacrusis measures within the piece (relevant for `mc_offset` column)
            and make it possible to omit a repeat sign in the following bar (relevant for `next` column).
            Set to False if you want to ignore section breaks.
        secure : :obj:`bool`, default False
            By default, measure information from lower staves is considered to contain only redundant information.
            Set to True if you want to be warned about additional measure information from lower staves that is not taken into account.
        reset_index : :obj:`bool`, default True
            By default, the original index of `df` is replaced. Pass False to keep original index values.
        logger_name : :obj:`str`, optional
            If you have defined a logger, pass its name.
        level : {'W', 'D', 'I', 'E', 'C', 'WARNING', 'DEBUG', 'INFO', 'ERROR', 'CRITICAL'}, optional
            Pass a level name for which (and above which) you want to see log records.

        cols : :obj:`dict`
            Dictionary of the relevant columns in `df` as present after the parse.
        ml : :obj:`pandas.DataFrame`
            The measure list in the making; the final result.
        volta_structure : :obj:`dict`
            Keys are first MCs of volta groups, values are dictionaries of {volta_no: [mc1, mc2 ...]}

        """

        def __init__(self, df, section_breaks=True, secure=False, reset_index=True, logger_name='MeasureList', level=None):
            self.logger = get_logger(logger_name, level=level)
            self.df = df
            self.ml = pd.DataFrame()
            self.section_breaks = section_breaks
            self.secure = secure
            self.reset_index = reset_index
            self.volta_structure = {}
            self.cols = {'barline': 'voice/BarLine/subtype',
                         'breaks': 'LayoutBreak/subtype',
                         'dont_count': 'irregular',
                         'endRepeat': 'endRepeat',
                         'keysig_col': 'voice/KeySig/accidental',
                         'len_col': 'Measure:len',
                         'mc': 'mc',
                         'numbering_offset': 'noOffset',
                         'sigN_col': 'voice/TimeSig/sigN',
                         'sigD_col': 'voice/TimeSig/sigD',
                         'staff': 'staff',
                         'startRepeat': 'startRepeat',
                         'volta_start': 'voice/Spanner/Volta/endings',
                         'volta_length': 'voice/Spanner/next/location/measures',
                         'volta_frac': 'voice/Spanner/next/location/fractions'}
            self.make_ml()

        def make_ml(self, section_breaks=True, secure=False, reset_index=True, logger_name=None, level=None):
            if logger_name is not None:
                self.logger = get_logger(logger_name, level=level)
            self.section_breaks = section_breaks
            self.secure = secure
            self.reset_index = reset_index

            self.ml = self.get_unique_measure_list()
            for col in [self.cols[col] for col in ['barline', 'breaks', 'dont_count', 'endRepeat', 'len_col', 'numbering_offset', 'startRepeat', 'volta_start', 'volta_length']]:
                if not col in self.ml.columns:
                    self.ml[col] = np.nan
            self.ml.rename(columns={self.cols['breaks']: 'breaks'}, inplace=True)
            sections = 'breaks' if self.section_breaks else None
            get_cols = lambda l: {col: self.cols[col] for col in l}
            volta_cols = get_cols(['mc', 'volta_start', 'volta_length'])
            if self.cols['volta_frac'] in self.ml.columns:
                volta_cols['frac_col'] = self.cols['volta_frac']
            self.volta_structure = get_volta_structure(self.ml, **volta_cols, logger=self.logger)
            func_params = {
                make_mn_col: get_cols(['dont_count', 'numbering_offset']),
                make_keysig_col: get_cols(['keysig_col']),
                make_timesig_col: get_cols(['sigN_col', 'sigD_col']),
                make_actdur_col: get_cols(['len_col']),
                make_repeat_col: get_cols(['startRepeat', 'endRepeat']),
                make_volta_col: {'volta_structure': self.volta_structure},
                make_next_col: {'mc_col': self.cols['mc'], 'volta_structure': self.volta_structure, 'section_breaks': sections,
                                'logger': self.logger},
                make_offset_col: {'mc_col': self.cols['mc'], 'section_breaks': sections, 'logger': self.logger},
            }
            for func, params in func_params.items():
                self.add_col(func, **params)
            if reset_index:
                self.ml.reset_index(drop=True, inplace=True)
            rn = {self.cols[col]: col for col in ['barline', 'dont_count', 'numbering_offset']}
            self.ml.rename(columns=rn, inplace=True)
            self.ml = self.ml[['mc', 'mn', 'keysig', 'timesig', 'act_dur', 'mc_offset', 'breaks',
                               'repeats', 'volta', 'barline', 'numbering_offset', 'dont_count',
                               'next']]
            self.ml[['numbering_offset', 'dont_count']] = self.ml[['numbering_offset', 'dont_count']].apply(pd.to_numeric).astype('Int64')
            self.check_measure_numbers()

        def add_col(self, func, **kwargs):
            """ Inserts or appends a column created by `func(df, **kwargs)`
            """
            new_col = func(self.ml, **kwargs)
            self.ml[new_col.name] = new_col

        def get_unique_measure_list(self, **kwargs):
            """ Keep only the measure information from the first staff.
            Uses: keep_one_row_each()

            Parameters
            ----------
            mc_col, staff_col : :obj:`str`, optional
                DataFrame columns where MC and staff can be found. Staff is to be dropped.
            secure : :obj:`bool`
                If the dropped rows contain additional information, set `secure` to True to
                be informed about the information being lost by the function keep_one_row_each().
            **kwargs: Additional parameter passed on to keep_one_row_each(). Ignored if `secure=False`.
            """
            if not self.secure:
                return self.df.drop_duplicates(subset=self.cols['mc']).drop(columns=self.cols['staff'])
            return keep_one_row_each(self.df, compress_col=self.cols['mc'], differentiating_col=self.cols['staff'], logger=self.logger)

        def check_measure_numbers(self, mc_col='mc', mn_col='mn', act_dur='act_dur', mc_offset='mc_offset',
                                  dont_count='dont_count', numbering_offset='numbering_offset'):
            def ordinal(i):
                if i == 1:
                    return '1st'
                elif i == 2:
                    return '2nd'
                elif i == 3:
                    return '3rd'
                return f'{i}th'
            mc2mn = dict(self.ml[[mc_col, mn_col]].itertuples(index=False))
            # Check measure numbers in voltas
            for volta_group in self.volta_structure.values():
                for i, t in enumerate(zip(*volta_group.values()), start=1):
                    m = t[0]
                    mn = mc2mn[m]
                    for j, mc in enumerate(t[1:], start=2):
                        current_mn = mc2mn[mc]
                        if current_mn != mn:
                            self.logger.warning(
                                f"MC {mc}, the {ordinal(i)} measure of a {ordinal(j)} volta, should have MN {mn}, not MN {current_mn}.")

            # Check measure numbers for split measures
            error_mask = (self.ml[mc_offset] > 0) & self.ml[dont_count].isna() & self.ml[numbering_offset].isna()
            n_errors = error_mask.sum()
            if n_errors > 0:
                mcs = ', '.join(self.ml.loc[error_mask, mc_col].astype(str))
                context_mask = error_mask | error_mask.shift(-1).fillna(False) | error_mask.shift().fillna(False)
                context = self.ml.loc[context_mask, [mc_col, mn_col, act_dur, mc_offset, dont_count, numbering_offset]]
                plural = n_errors > 1
                self.logger.warning(
                    f"MC{'s' if plural else ''} {mcs} seem{'' if plural else 's'} to be offset from the MN's beginning but ha{'ve' if plural else 's'} not been excluded from barcount. Context:\n{context}")

    class NextColumnMaker(object):

        def __init__(self, mc, volta_structure, logger=None):
            self.logger = get_logger(logger) if logger is None else logger
            self.mc = mc  # Series
            nxt = self.mc.shift(-1).astype('Int64').map(lambda x: [x] if not pd.isnull(x) else [-1])
            self.next = {mc: nx for mc, nx in zip(self.mc, nxt)}
            self.start = None
            self.potential_start = None
            self.potential_ending = None
            self.check_volta_repeats = {}
            self.wasp_nest = {}
            for first_mc, group in volta_structure.items():
                firsts = []
                lasts = []
                last_volta = max(group)
                for volta, mcs in group.items():
                    # every volta except the last needs a repeat sign and will have the
                    # `next` value replaced by the startRepeat MC
                    if volta < last_volta:
                        lasts.append(mcs[-1])
                    # the bar before the first volta will have first bar of every volta as `next`
                    firsts.append(mcs[0])
                self.next[first_mc - 1] = firsts
                self.check_volta_repeats.update({l: False for l in lasts})
                self.wasp_nest.update({l: lasts for l in lasts})

        def start_section(self, mc):
            if self.start is not None:
                if self.potential_ending is None:
                    self.logger.warning(f"""The startRepeat in MC {self.start} is missing its endRepeat.
        For correction, MC {mc - 1} is interpreted as such because it precedes the next startRepeat.""")
                    self.end_section(mc - 1)
                else:
                    ending, reason = self.potential_ending
                    self.logger.warning(f"""The startRepeat in MC {self.start} is missing its endRepeat.
        For correction, MC {ending} is interpreted as such because it {reason}.""")
                    self.end_section(ending)
            self.start = mc
            self.potential_start = None
            self.potential_ending = None

        def end_section(self, mc):
            if self.start is not None:
                start = self.start
            elif self.potential_start is not None:
                start, reason = self.potential_start
                if reason == 'firstMeasure':
                    self.logger.debug(
                        f"MC {start} has been inferred as startRepeat for the endRepeat in MC {mc} because it is the first bar of the piece.")
                else:
                    msg = f"""The endRepeat in MC {mc} is missing its startRepeat.
        For correction, MC {start} is interpreted as such because it {reason}."""
                    if "section break" in msg:
                        self.logger.debug(msg)
                    else:
                        self.logger.warning(msg)
            else:
                start = None

            if mc in self.check_volta_repeats:
                self.check_volta_repeats[mc] = True
                if mc in self.wasp_nest:
                    if start is None:
                        self.logger.error(
                            f"No starting point for the repeatEnd in MC {mc} could be determined. It is being ignored.")
                    else:
                        volta_endings = self.wasp_nest[mc]
                        for e in volta_endings:
                            self.next[e] = [start]
                            del (self.wasp_nest[e])
                        self.start = None
            elif start is None:
                self.logger.error(f"No starting point for the repeatEnd in MC {mc} could be determined. It is being ignored.")
            else:
                self.next[mc] = [start] + self.next[mc]
                if self.potential_start is not None:
                    pot_mc, reason = self.potential_start
                    if pot_mc == mc + 1:
                        self.potential_start = (pot_mc, reason + ' and the previous endRepeat')
                    else:
                        self.potential_start = (mc + 1, 'is the first bar after the previous endRepeat')
                else:
                    self.potential_start = (mc + 1, 'is the first bar after the previous endRepeat')
                self.start = None

        def treat_input(self, mc, repeat, section_break=False):
            if section_break:
                self.potential_ending = (mc, 'precedes a section break')
                self.potential_start = (mc + 1, 'follows a section break')

            if repeat == 'firstMeasure':
                self.potential_start = (mc, 'firstMeasure')
            elif repeat == 'start':
                self.start_section(mc)
            elif repeat == 'startend':
                self.start_section(mc)
                self.end_section(mc)
            elif repeat == 'end':
                self.end_section(mc)
            elif repeat == 'lastMeasure':
                if self.start is not None:
                    self.potential_ending = (mc, 'is the last bar of the piece.')
                    self.start_section(mc + 1)
                    self.start = None

    @function_logger
    def get_volta_structure(df, mc, volta_start, volta_length, frac_col=None):
        """
            Uses: treat_group()
        """
        cols = [mc, volta_start, volta_length]
        sel = df[volta_start].notna()
        voltas = (df.loc[sel, cols])
        if voltas[volta_length].isna().sum() > 0:
            rows = voltas[voltas[volta_length].isna()]
            logger.debug(f"The volta in MC {rows[mc].values} has no length: A standard length of 1 is supposed.")
            voltas[volta_length] = voltas[volta_length].fillna(0)
        voltas = voltas.astype(int)
        if len(voltas) == 0:
            return {}
        if frac_col is not None:
            voltas[volta_length] += df.loc[sel, frac_col].notna()
        voltas.loc[voltas[volta_start] == 1, 'group'] = 1
        voltas.group = voltas.group.fillna(0).astype(int).cumsum()
        groups = {v[mc].iloc[0]: v[cols].to_numpy() for _, v in voltas.groupby('group')}
        res = {mc: treat_group(mc, group, logger=logger) for mc, group in groups.items()}
        logger.debug(f"Inferred volta structure: {res}")
        return res

    @function_logger
    def keep_one_row_each(df, compress_col, differentiating_col, differentiating_val=None, ignore_columns=None, fillna=True,
                          drop_differentiating=True):
        """ Eliminates duplicates in `compress_col` but warns about values within the
            dropped rows which diverge from those of the remaining rows. The `differentiating_col`
            serves to identify places where information gets lost during the process.

        The result of this function is the same as `df.drop_duplicates(subset=[compress_col])`
        if `differentiating_val` is None, and `df[df[compress_col] == differentiating_val]` otherwise
        but with the difference that only adjacent duplicates are eliminated.

        Parameters
        ----------
        compress_col : :obj:`str`
            Column with duplicates (e.g. measure counts).
        differentiating_col : :obj:`str`
            Column that differentiates duplicates (e.g. staff IDs).
        differentiating_val : value, optional
            If you want to keep rows with a certain `differentiating_col` value, pass that value (e.g. a certain staff).
            Otherwise, the first row of every `compress_col` value is kept.
        ignore_columns : :obj:`Iterable`, optional
            These columns are not checked.
        fillna : :obj:`bool`, optional
            By default, missing values of kept rows are filled if the dropped rows contain
            one unique value in that particular column. Pass False to keep rows as they are.
        drop_differentiating : :obj:`bool`, optional
            By default, the column that differentiates the `compress_col` is dropped.
            Pass False to prevent that.
        """
        if ignore_columns is None:
            ignore_columns = [differentiating_col]
        else:
            ignore_columns.append(differentiating_col)
        consider_for_notna = [col for col in df.columns if not col in ignore_columns + [compress_col]]
        consider_for_duplicated = consider_for_notna + [compress_col]
        empty_rows = df[consider_for_duplicated].isnull().all(axis=1)
        if differentiating_val is not None:
            keep_rows = df[differentiating_col] == differentiating_val
        else:
            keep_rows = df[compress_col] != df[compress_col].shift()
        drop_rows = ~keep_rows & empty_rows
        result = df.drop(df[drop_rows].index)

        def compress(df):
            if len(df) == 1:
                return df.iloc[0]
            if differentiating_val is None:
                keep_row = df.iloc[[0]].copy()
                remaining = df.iloc[1:].drop_duplicates(subset=consider_for_duplicated)
            else:
                keep = df[differentiating_col] == differentiating_val
                keep_row = df[keep].copy()
                assert len(keep_row) == 1, "The column designated by `differentiating_col` needs to be unique."
                remaining = df[~keep].drop_duplicates(subset=consider_for_duplicated)
            if len(remaining) == 1:
                return keep_row
            which = keep_row[compress_col]
            for val, (col_name, col) in zip(*keep_row[consider_for_notna].itertuples(index=False, name=None),
                                            remaining[consider_for_notna].items()):
                if col.isna().all():
                    continue
                vals = col[col.notna()].unique()
                if len(vals) == 1:
                    if vals[0] == val:
                        continue
                    new_val = vals[0]
                    if pd.isnull(val) and fillna:
                        keep_row[col_name] = new_val
                        logger.warning(
                            f"{compress_col} {which}: The missing value in '{col_name}' was replaced by '{new_val}', present in {differentiating_col} {remaining.loc[remaining[col_name] == new_val, differentiating_col].values}.")
                        continue
                    logger.warning(
                        f"{compress_col} {which}: The value '{new_val}' in '{col_name}' of {differentiating_col} {remaining.loc[remaining[col_name] == new_val, differentiating_col].values} is lost.")
                    continue
                logger.warning(
                    f"{compress_col} {which}: The values {vals} in '{col_name}' of {differentiating_col} {remaining.loc[col.notna(), differentiating_col].values} are lost.")
            return keep_row

        result = result.groupby(compress_col, group_keys=False).apply(compress)
        return result.drop(columns=differentiating_col) if drop_differentiating else result

    def make_actdur_col(df, len_col, timesig_col='timesig', name='act_dur'):
        actdur = df[len_col]
        actdur = actdur.fillna(df[timesig_col])
        return actdur.map(frac).rename(name)

    def make_keysig_col(df, keysig_col, name='keysig'):
        if keysig_col in df:
            return df[keysig_col].fillna(method='ffill').fillna(0).astype(int).rename(name)
        return pd.Series(0, index=df.index).rename(name)

    def make_mn_col(df, dont_count, numbering_offset, name='mn'):
        """ Compute measure numbers where one or two columns can influence the counting.

        Parameters
        ----------
        df : :obj:`pd.DataFrame`
            If no other parameters are given, every row is counted, starting from 1.
        dont_count : :obj:`str`, optional
            This column has notna() for measures where the option "Exclude from bar count" is activated, NaN otherwise.
        numbering_offset : :obj:`str`, optional
            This column has values of the MuseScore option "Add to bar number", which adds
            notna() values to this and all subsequent measures.
        """
        if dont_count is None:
            mn = pd.Series(range(1, len(df) + 1), index=df.index)
        else:
            excluded = df[dont_count].fillna(0).astype(bool)
            mn = (~excluded).cumsum()
        if numbering_offset is not None:
            offset = df[numbering_offset]
            if offset.notna().any():
                offset = offset.fillna(0).astype(int).cumsum()
                mn += offset
        return mn.rename(name)

    @function_logger
    def make_next_col(df, mc_col='mc', repeats='repeats', volta_structure={}, section_breaks=None, name='next'):
        """ Uses a `NextColumnMaker` object to create a column with all MCs that can follow each MC (e.g. due to repetitions).

        Parameters
        ----------
        df : :obj:`pandas.DataFrame`
            Raw measure list.
        mc_col, repeats : :obj:`str`, optional
            Column names.
        volta_structure : :obj:`dict`, optional
            This parameter can be computed by get_volta_structure(). It is empty if
            there are no voltas in the piece.
        section_breaks : :obj:`str`, optional
            If you pass the name of a column, the string 'section' is taken into account
            as ending a section and therefore potentially ending a repeated part even when
            the repeat sign is missing.
        """
        if section_breaks is not None and len(df[df[section_breaks].fillna('') == 'section']) == 0:
            section_breaks = None
        if section_breaks is None:
            cols = [mc_col, repeats]
            sel = df[repeats].notna()
        else:
            cols = [mc_col, repeats, section_breaks]
            sel = df[repeats].notna() | df[section_breaks].notna()

        ncm = NextColumnMaker(df[mc_col], volta_structure, logger=logger)
        for t in df.loc[sel, cols].itertuples(index=False):
            if section_breaks is None:
                mc, repeat = t
                ncm.treat_input(mc, repeat)
            else:
                mc, repeat, section_break = t
                ncm.treat_input(mc, repeat, section_break == 'section')

        for mc, has_repeat in ncm.check_volta_repeats.items():
            if not has_repeat:
                logger.warning(f"MC {mc} is missing an endRepeat.")

        nxt_col = df[mc_col].map(ncm.next).map(tuple)

        return nxt_col.rename(name)

    @function_logger
    def make_offset_col(df, mc_col='mc', timesig='timesig', act_dur='act_dur', next_col='next', section_breaks=None, name='mc_offset'):
        """ If one MN is composed of two MCs, the resulting column indicates the second MC's offset from the MN's beginning.

        Parameters
        ----------
        mc_col, timesig, act_dur, next_col : :obj:`str`, optional
            Names of the required columns.
        section_breaks : :obj:`str`, optional
            If you pass the name of a column, the string 'section' is taken into account
            as ending a section and therefore potentially ending a repeated part even when
            the repeat sign is missing.
        """
        nom_dur = df[timesig].map(frac)
        sel = df['act_dur'] < nom_dur
        if sel.sum() == 0:
            return pd.Series(0, index=df.index, name=name)

        if section_breaks is not None and len(df[df[section_breaks].fillna('') == 'section']) == 0:
            section_breaks = None
        cols = [mc_col, next_col]
        if section_breaks is not None:
            cols.append(section_breaks)
            last_mc = df[mc_col].max()
            offsets = {m: 0 for m in df[df[section_breaks].fillna('') == 'section'].mc + 1 if m <= last_mc}
            # offset == 0 is a neutral value but the presence of mc in offsets indicates that it could potentially be an
            # (incomplete) pickup measure which can be offset even if the previous measure is complete
        else:
            offsets = {}
        nom_durs = dict(df[[mc_col]].join(nom_dur).itertuples(index=False))
        act_durs = dict(df[[mc_col, act_dur]].itertuples(index=False))

        def missing(mc):
            return nom_durs[mc] - act_durs[mc]

        def add_offset(mc, val=None):
            if val is None:
                val = missing(mc)
            offsets[mc] = val

        irregular = df.loc[sel, cols]
        if irregular[mc_col].iloc[0] == 1:
            # Check whether first MC is an anacrusis and mark accordingly
            if len(irregular) > 1 and irregular[mc_col].iloc[1] == 2:
                if not missing(1) + act_durs[2] == nom_durs[1]:
                    add_offset(1)
                else:
                    # regular divided measure, no anacrusis
                    pass
            else:
                # is anacrusis
                add_offset(1)
        for t in irregular.itertuples(index=False):
            if section_breaks:
                mc, nx, sec = t
                if sec == 'section':
                    nxt = [i for i in nx if i <= mc]
                    if len(nxt) == 0:
                        logger.debug(f"MC {mc} ends a section with an incomplete measure.")
                else:
                    nxt = [i for i in nx]
            else:
                mc, nx = t
                nxt = [i for i in nx]
            if mc not in offsets:
                completions = {m: act_durs[m] for m in nxt if m > -1}
                expected = missing(mc)
                errors = sum(True for c in completions.values() if c != expected)
                if errors > 0:
                    logger.warning(
                        f"The incomplete MC {mc} (timesig {nom_durs[mc]}, act_dur {act_durs[mc]}) is completed by {errors} incorrect duration{'s' if errors > 1 else ''} (expected: {expected}):\n{completions}")
                for compl in completions.keys():
                    add_offset(compl)
            elif offsets[mc] == 0:
                add_offset(mc)
        mc2ix = {m: ix for ix, m in df.mc.iteritems()}
        result = {mc2ix[m]: offset for m, offset in offsets.items()}
        return pd.Series(result, name=name).reindex(df.index, fill_value=0)

    def make_repeat_col(df, startRepeat, endRepeat, name='repeats'):
        repeats = df[startRepeat].copy()
        ends = df[endRepeat]
        sel = dict(
            start=repeats.notna() & ends.isna(),
            startend=repeats.notna() & ends.notna(),
            end=repeats.isna() & ends.notna()
        )
        for case, arr in sel.items():
            repeats.loc[arr] = case
        if pd.isnull(repeats.iloc[0]):
            repeats.iloc[0] = 'firstMeasure'
        if pd.isnull(repeats.iloc[-1]):
            repeats.iloc[-1] = 'lastMeasure'
        return repeats.rename(name)

    def make_timesig_col(df, sigN_col, sigD_col, name='timesig'):
        n = pd.to_numeric(df[sigN_col].fillna(method='ffill')).astype(str)
        d = pd.to_numeric(df[sigD_col].fillna(method='ffill')).astype(str)
        return (n + '/' + d).rename(name)

    def make_volta_col(df, volta_structure, mc='mc', name='volta'):
        """ Create the input for `volta_structure` using get_volta_structure()
        """
        mc2volta = {mc: volta for group in volta_structure.values() for volta, mcs in group.items() for mc in mcs}
        return df[mc].map(mc2volta).astype('Int64').rename(name)

    @function_logger
    def treat_group(mc, group):
        """ Helper function for make_volta_col()
            Input example: array([[93,  1,  1], [94,  2,  2], [96,  3,  1]])
            where columns are (MC, volta number, volta length).
        """
        n = group.shape[0]
        mcs, numbers, lengths = group.T
        # check volta numbers
        expected = np.arange(1, n + 1)
        if (numbers != expected).any():
            logger.warning(f"Volta group of MC {mc} should have voltas {expected.tolist()} but has {numbers.tolist()}")
        # check volta lengths
        frst = lengths[0]
        if (lengths != frst).any():
            logger.warning(
                f"Volta group of MC {mc} contains voltas with different lengths: {lengths.tolist()} Check for correct computation of MNs.")
        # check for overlaps and holes
        boundaries = np.append(mcs, mcs[-1] + group[-1, 2])
        correct = {i: np.arange(fro, to).tolist() for i, (fro, to) in
                   enumerate(zip(boundaries[:-1], boundaries[1:]), start=1)}
        in_score = {i: [row[0] + i for i in range(row[2])] for i, row in enumerate(group, start=1)}
        if in_score != correct:
            logger.warning(
                f"The incorrect structure {in_score} of the volta groupa of MC {mc} has been corrected to {correct}.")
        return correct