Merge equivalent compounds in various compartments.

    Tries to detect and merge compound entries that represent the same
    compound in different compartments. The entries are only merged if all
    properties are equivalent. Compound entries must have an ID with a suffix
    of an underscore followed by the compartment ID. This suffix will be
    stripped and compounds with identical IDs are merged if the properties
    are identical.

    Args:
        model: :class:`NativeModel`.

def merge_equivalent_compounds(model):
    """Merge equivalent compounds in various compartments.

    Tries to detect and merge compound entries that represent the same
    compound in different compartments. The entries are only merged if all
    properties are equivalent. Compound entries must have an ID with a suffix
    of an underscore followed by the compartment ID. This suffix will be
    stripped and compounds with identical IDs are merged if the properties
    are identical.

    Args:
        model: :class:`NativeModel`.
    """
    def dicts_are_compatible(d1, d2):
        return all(key not in d1 or key not in d2 or d1[key] == d2[key]
                   for key in set(d1) | set(d2))

    compound_compartment = {}
    inelegible = set()
    for reaction in model.reactions:
        equation = reaction.equation
        if equation is None:
            continue

        for compound, _ in equation.compounds:
            compartment = compound.compartment
            if compartment is not None:
                compound_compartment[compound.name] = compartment
                if not compound.name.endswith('_{}'.format(compartment)):
                    inelegible.add(compound.name)

    compound_groups = {}
    for compound_id, compartment in iteritems(compound_compartment):
        if compound_id in inelegible:
            continue

        suffix = '_{}'.format(compound_compartment[compound_id])
        if compound_id.endswith(suffix):
            group_name = compound_id[:-len(suffix)]
            compound_groups.setdefault(group_name, set()).add(compound_id)

    compound_mapping = {}
    merged_compounds = {}
    for group, compound_set in iteritems(compound_groups):
        # Try to merge as many compounds as possible
        merged = []
        for compound_id in compound_set:
            props = dict(model.compounds[compound_id].properties)

            # Ignore differences in ID and compartment properties
            props.pop('id', None)
            props.pop('compartment', None)

            for merged_props, merged_set in merged:
                if dicts_are_compatible(props, merged_props):
                    merged_set.add(compound_id)
                    merged_props.update(props)
                    break
                else:
                    keys = set(key for key in set(props) | set(merged_props)
                               if key not in props or
                               key not in merged_props or
                               props[key] != merged_props[key])
                    logger.info(
                        'Unable to merge {} into {}, difference in'
                        ' keys: {}'.format(
                            compound_id, ', '.join(merged_set),
                            ', '.join(keys)))
            else:
                merged.append((props, {compound_id}))

        if len(merged) == 1:
            # Merge into one set with the group name
            merged_props, merged_set = merged[0]

            for compound_id in merged_set:
                compound_mapping[compound_id] = group
            merged_compounds[group] = merged_props
        else:
            # Since we cannot merge all compounds, create new group names
            # based on the group and compartments.
            for merged_props, merged_set in merged:
                compartments = set(compound_compartment[c] for c in merged_set)
                merged_name = '{}_{}'.format(
                    group, '_'.join(sorted(compartments)))

                for compound_id in merged_set:
                    compound_mapping[compound_id] = merged_name
                merged_compounds[merged_name] = merged_props

    # Translate reaction compounds
    for reaction in model.reactions:
        equation = reaction.equation
        if equation is None:
            continue

        reaction.equation = equation.translated_compounds(
            lambda c: compound_mapping.get(c, c))

    # Translate compound entries
    new_compounds = []
    for compound in model.compounds:
        if compound.id not in compound_mapping:
            new_compounds.append(compound)
        else:
            group = compound_mapping[compound.id]
            if group not in merged_compounds:
                continue
            props = merged_compounds.pop(group)
            props['id'] = group
            new_compounds.append(DictCompoundEntry(
                props, filemark=compound.filemark))

    model.compounds.clear()
    model.compounds.update(new_compounds)

    # Translate exchange
    new_exchange = OrderedDict()
    for compound, reaction_id, lower, upper in itervalues(model.exchange):
        new_compound = compound.translate(
            lambda name: compound_mapping.get(name, name))
        new_exchange[new_compound] = new_compound, reaction_id, lower, upper

    model.exchange.clear()
    model.exchange.update(new_exchange)